Compare commits

..
57 Commits
Author SHA1 Message Date
Lauri Ojansivu 58ad81e301 continuousBackup integration suite: stop each engine before its directory is removed.
Three tests registered the hook that removes their temp directory before the hook
that stops their engine. node:test runs after-hooks in the order they were
registered, not last-in first-out, so the directory was gone when stop() sealed
into it, and the ENOENT (files/index.ndjson) surfaced after the test as
"generated asynchronous activity after the test ended" - which failed the whole
suite although all 8 tests passed. One hook now stops the engine first and then
removes the directory. Five runs in a row, and the suite runner, pass.

Thanks to xet7 !
2026-11-04 19:20:58 +02:00
Lauri Ojansivu 2912f4b080 Close #6870: attachments are already off site-wide in Admin Panel / Attachments / Transfer limits.
How: Admin Panel / Attachments / Transfer limits, set "Attachments upload" and
"API upload" to Blocked. That is the site-wide "Disable attachments" the issue
asks for, as two settings instead of one checkbox.

Comment for the issue:

  A site-wide switch for this already exists, so a new "Disable attachments"
  setting is not needed. Open Admin Panel / Attachments / Transfer limits and set
  "Attachments upload" and "API upload" to Blocked. While they are on, the server
  refuses an upload on every path - the upload pipeline, the api.attachment.upload
  and api.attachment.copy methods, and the /api/attachment upload,
  upload-background and copy routes - on every board, including boards created
  later, and no board's allowsAttachments flag has to be touched. Download, move
  and delete are not affected, so files uploaded earlier stay reachable. What was
  missing is that a card still showed the "+" button and accepted a drop for an
  upload that would then be refused; since d8b3579117 the card asks the server
  once a minute and hides both. There is no second setting, so nothing can
  disagree with what the server enforces. Two settings instead of one checkbox
  is the one difference from the request.

Thanks to Raymond-Voogt and xet7 !

Fixes #6870,
2026-11-04 19:17:53 +02:00
Lauri Ojansivu 97d1eca09d CHANGELOG: In short for the upcoming release. 2026-11-04 19:12:14 +02:00
Lauri Ojansivu 95b75177b8 CHANGELOG: continuous backup runs on one server at a time, with a lease. 2026-11-04 19:12:06 +02:00
Lauri Ojansivu 880ec2d707 Continuous backup runs on one server at a time, with a lease.
With several WeKan servers on one database, every server applied the continuous
backup setting, so each of them tailed the oplog and wrote its own archives into
the same target directory (#6861, item 3).

The leader holds a lease: one continuousBackupLease document with its id and an
expiry. Taking it is one atomic operation (an insert while there is none, else
an update that matches only an unheld, own or expired lease), the leader renews
it every 15 seconds and it lapses after 45, so another server takes over within
a minute when the leader stops. A server that loses the lease stops its stream.
Startup, saving the settings and a change saved on another server all converge
through that loop, and a restore or a fetch from the cloud takes the lease and
waits one renewal so it is the only writer. One server is always its own leader.

Checked against the real driver on FerretDB (SQLite): 24 clients at once give
exactly one leader; renewal, expiry, loss of the old leader and release behave.
tests/continuousBackupLease.test.cjs pins the same and the wiring in
server/continuousBackup.js. docs/Backup/Continuous-Backup.md no longer says
single-server only.

Thanks to Raymond-Voogt and xet7 !

Refs #6861, not closed: the remaining items of the issue were fixed in 4e28465964.
2026-11-04 19:11:32 +02:00
Lauri Ojansivu dbc7cccea3 CHANGELOG: a card no longer offers the attachment plus when uploads are blocked for the whole site. 2026-11-04 19:07:11 +02:00
Lauri Ojansivu d8b3579117 A card no longer offers the attachment "+" when uploads are blocked for the whole site.
#6870 asks for a site-wide switch that turns attachments off, because the
per-board allowsAttachments flag has to be set on every board. That switch is
there already: Admin Panel / Attachments / Transfer limits, "Attachments upload"
and "API upload" set to Blocked, and the server refuses on every path - the
upload pipeline, api.attachment.upload and .copy, and the /api/attachment
upload, upload-background and copy routes. What was missing is that the card
still showed the "+" button and accepted a drop for an upload that would be
refused.

attachmentUploadPolicy tells a signed-in client the one boolean (nothing about
the limits), asked at most once a minute, and the "+" and the drop/paste upload
follow it. No second setting is added, so there is nothing that could disagree
with what the server enforces. tests/attachmentUploadPolicy.test.cjs pins the
client, the method, and the server enforcement it relies on, and that no
"disableAttachments" setting exists.

Thanks to Raymond-Voogt and xet7 !

Refs #6870, not closed: the reporter asked for a single Features checkbox, and the
two Blocked limits are two settings.
2026-11-04 19:07:09 +02:00
Lauri Ojansivu c7e53e9f38 CHANGELOG: an integration update for a missing or wrong-board integration returns 404. 2026-11-04 19:04:14 +02:00
Lauri Ojansivu 6012521ccc An integration update for a missing or wrong-board integration returns 404.
PUT /api/boards/:boardId/integrations/:intId answered 200 with the requested id
when no integration matched that id and board (PR #6871). The two activities
routes (POST and DELETE .../activities) had the same fault: they updated
nothing and answered 200 with `data: null`. All three now look the integration
up on that board after board-admin authorization and answer the JSON 404 the GET
route already uses (#6862), before validating or writing anything.

tests/restIntegrationUpdate.test.cjs and the Playwright spec are the PR's.
tests/restIntegrationWriteRoutes404.test.cjs adds the activities routes and
pins, for the whole file, that every handler that updates an integration looks
it up first and only after authorization.

Thanks to AhmedLukman and xet7 !
2026-11-04 19:04:12 +02:00
Lauri Ojansivu 0195a5b3d4 Merge remote-tracking branch 'origin/main' 2026-11-04 15:02:36 +02:00
Lauri Ojansivu b12e761afb CHANGELOG: missing-board exports return 404 and an over-long export token 400. 2026-11-04 14:50:49 +02:00
Lauri Ojansivu 0c0d666ffa Missing-board exports return 404, and an over-long export token returns 400.
The board Excel export answered a missing board with HTTP 200 and the text
"Board not found" (PR #6869). The two PDF export routes had the same fault, and
the Excel and PDF routes answered an over-long auth token with 200 too, while
export.js already used 404 and 400. They now choose their status the same way.

tests/boardExcelNotFound.test.cjs and the Playwright spec are the PR's.
tests/exportErrorStatuses.test.cjs scans every models/export*.js route for a
text answer without a status, so the fault cannot return anywhere there.

Thanks to AhmedLukman and xet7 !
2026-11-04 14:50:33 +02:00
Lauri Ojansivu fc2ad0a48b CHANGELOG: nodemailer, body-parser, qs, uuid and postcss-selector-parser raised in the bundle. 2026-11-04 14:45:57 +02:00
Lauri Ojansivu dad03959d9 Raise nodemailer, body-parser, qs, uuid and postcss-selector-parser in the bundle.
A Grype scan of the image found advisories in packages that Meteor's own
packages carry in programs/server/npm/node_modules/meteor/<package>, which
package.json cannot reach.

nodemailer 8.0.11 -> 10.3.1: meteor/email only calls createTransport, the
'stream' plugin hook, nodemailer/lib/well-known and nodemailer/lib/mail-composer,
and all four work in 10.3.1 (checked with the bundle's own package specifiers,
through its exports map). body-parser 1.20.6/1.20.8 -> 1.20.9 and 2.3.0 ->
2.4.0: a minimum may now be "name@MAJOR", so each major is raised inside
itself. qs -> 6.16.1, postcss-selector-parser 6.1.4 -> 6.1.5.

uuid 8.3.2 and 9.0.1 -> 11.1.1: 11 moved its entry point, which is why it was
refused before. The bumper now writes the recorded dist/index.js back as an
ESM re-export of dist/esm/index.js, which require() loads and which leaves
uuid's own ESM and CJS entry points as they are.

glob 14 and minimatch 11 are already in package.json. Run against a copy of
the published image's bundle, the bumper raises all of these and every raised
copy loads.

Thanks to the Grype scan reporter and xet7 !
2026-11-04 14:45:04 +02:00
github-actions[bot] ac960e7d81 Bump versions for v12.94 2026-11-04 01:07:15 +00:00
Lauri Ojansivu 3213d7fa1d Prepare v12.94 release 2026-11-04 03:06:07 +02:00
Lauri Ojansivu 4757676abf CHANGELOG: on a phone in desktop mode the opened card starts below the header bar. 2026-11-04 03:04:05 +02:00
Lauri Ojansivu efaf94242e On a phone in desktop mode the opened card starts below the header bar.
The header wraps to several rows on a phone, far taller than the 8px the
floating card docks at, so the card's top row sat among the header's rows and
was only seen by dragging. Below 801px a card without an inline top, popup
cards included, starts at --wekan-header-height + 8px; a dragged place is an
inline top and is never overridden.

Thanks to the iPhone 12 mini reporter and xet7 !
2026-11-04 03:03:59 +02:00
Lauri Ojansivu d87baa0244 CHANGELOG: the opened card covers the header bar on an iPad. 2026-11-04 02:56:44 +02:00
Lauri Ojansivu 006d24502c The opened card covers the header bar on an iPad: the header drops to the content's level.
On iPad Chrome and Safari (both WebKit) the opened card's top row - the collapse
caret, the card number, the menu, maximize and X - was behind the header bar,
although the card has z-index 2001 and the header 1000. iOS WebKit makes every
scrolling element (#content, the board wrapper) a stacking context of its own, so
the card's z-index is compared only inside it while the header bar, a flex item of
body and a stacking context too, is painted over all of it. While a card is open
the header bar (#header-quick-access) is now at z-index 0, the level of the
content, which comes after it in the page and so paints over it; with no card open
it keeps 1000.

A browser spec makes #content and the board wrapper stacking contexts with
isolation: isolate, which is the same trap, and shows the card over the header open
and collapsed, its close button reachable, in Chromium and WebKit; without the rule
it fails with the card's top at 8 behind a header 88 high, as in the screenshot.

Thanks to xet7 !
2026-11-04 02:56:33 +02:00
Lauri Ojansivu 8f4efbca57 Merge remote-tracking branch 'origin/main' 2026-11-04 01:35:33 +02:00
Lauri Ojansivu dde09dead5 CHANGELOG: where the time goes when a WeKan board is opened. 2026-11-04 01:21:48 +02:00
Lauri Ojansivu 3621413d6f Speed test: where the time goes when a WeKan board is opened, on FerretDB and on MongoDB.
app/board-open.cjs seeds a board of 200, 2000 or 5000 cards, opens it in Chromium and
records the wall clock to the first card and until the DDP WebSocket is quiet, what
crossed it, the Meteor server's CPU by who spends it (Node inspector) and FerretDB's
CPU (pprof). Two runs on each database: the Meteor server's CPU is the same on both
(0.1-0.5 s) and the DDP server is 1% of it; a 5000-card board takes FerretDB (SQLite)
400-600 ms longer to open, the data sent to the browser being the same ~210 KB, a
small board 70-100 ms. FerretDB uses about 4 s of CPU for the 5000-card open: SQLite
39%, filtering in Go 34%, find/getMore/aggregate 0.7-0.8 s each. The README says what
was measured and what was not: the development build, one machine, the browser's own
time not separated, and the aggregate's source not identified.

Thanks to Raymond-Voogt and xet7 !
2026-11-04 01:21:16 +02:00
github-actions[bot] 3c3fc07b3c Bump versions for v12.93 2026-11-03 23:04:07 +00:00
Lauri Ojansivu c1e8976b28 Prepare v12.93 release 2026-11-04 01:02:51 +02:00
Lauri Ojansivu dcce935310 CHANGELOG: the opened card and popups move under a finger. 2026-11-04 01:00:21 +02:00
Lauri Ojansivu 1b05673200 The opened card and every popup move under a finger: Pointer Events and touch-action.
On an iPad the opened card did not move when dragged by a finger from the collapse
caret, the space beside it, the card number, or the drag handle, while the page
behind it scrolled. The card was moved by mousedown / mousemove / mouseup, and a
finger sends none of them while it moves; the drag areas also lacked
touch-action: none, so the browser took the gesture for a scroll. The card window
is now moved by Pointer Events: a press on the header row or the handle moves it, a
press on a link or button in the row (the caret, close, the menu) waits for a real
drag so a tap still does its job and a drag does not also collapse or close, the
title still edits, and with drag handles on the handle alone moves the window. The
card number is above the title's edit zone so it drags too, the window is kept
within reach, and touch-action: none is on the card header, its handle and every
popup's title bar and handle. Blaze hands over a jQuery event without isPrimary, so
the native event is read.

A browser spec drags with a real touch (DevTools protocol, Pointer Events) from the
caret, the number and the space beside the title, with handles on and off, a tap
that still collapses, a mouse that still drags and a popup; without the fix the
finger tests fail.

Thanks to xet7 !
2026-11-04 01:00:19 +02:00
Lauri Ojansivu dafb63299f CHANGELOG: a popup card covers the header bars. 2026-11-04 00:42:59 +02:00
Lauri Ojansivu 84b0480aa8 An opened card opened as a popup covers the header bars, in every browser.
A card opened as a popup (a search result, a table row, a Roadmap/Gantt bar) got
the generic wide-layout rule top: 97px and z-index 30, because the desktop-mode
rules that dock an opened card at the top (top: 8px, z-index 2001) skip a popup.
So it opened BELOW the header, and collapsed it was a bar floating over the
swimlane, while the same file's popup wrapper rule says it must cover the header
bars. The popup card now docks at top: 8px with z-index 2001 in the wide layout;
mobile mode keeps its own full-window rule. A browser spec shows the card over
the header, open and collapsed, in Chromium and WebKit, and fails without the fix
(top 97 against a header 88 high).

Thanks to xet7 !
2026-11-04 00:42:57 +02:00
Lauri Ojansivu f6fc97e77a The full screen button says Minimize while the page is in full screen.
The same button leaves full screen; its title and name were still Maximize, which
does not say so. The existing Minimize translation is used, so no language file
changes.
2026-11-04 00:29:00 +02:00
Lauri Ojansivu d7105e531d CHANGELOG: a full screen button in the header bar. 2026-11-04 00:27:30 +02:00
Lauri Ojansivu f25957dcf3 A full screen button in the header bar, to the right of the logo.
It puts the whole page in full screen with the Fullscreen API (the unprefixed one
and the webkit-prefixed one) and takes it out again; the icon follows the
browser's own events, so Esc is understood. The label is the existing Maximize
translation, so no language file changes. It is drawn only where the browser
offers the API and the page may use it: not on iPhone, not in an iframe without
allowfullscreen. Unit tests drive the pure part with fake documents, including a
refusal; a browser spec checks the position, the label, entering, leaving, Esc,
and that nothing is drawn where the API is not enabled, in Chromium and WebKit.

Thanks to xet7 !
2026-11-04 00:27:28 +02:00
Lauri Ojansivu 984ea93aa8 CHANGELOG: Dependabot alerts 180 and 183-187, and the SQLite membership test fix. 2026-11-04 00:13:58 +02:00
Lauri Ojansivu 2441563008 Speed test: SQLite after the membership test fix (68.3 ms, 4.1x), the Due Cards profile and what was tried.
Thanks to Raymond-Voogt and xet7 !
2026-11-04 00:13:34 +02:00
Lauri Ojansivu f3950b9adc Dependabot alerts 180 and 183-187: minimatch in the other lockfiles, and the Go modules of the embedded benchmark.
minimatch before 11.0.1 (GHSA-g3hv-75wg-m249): npm-packages/exceljs still had
10.2.6 through readdir-glob 3, and the vendored jade lockfile under
meteor-jade-loader had three copies through glob 13 and test-exclude; both get
an override to minimatch 11, as the root lockfile has. A test now reads every
package-lock.json of the repository, not only the reported ones.

The benchmark module of embedded engines pulled klauspost/compress 1.18.0,
golang.org/x/net 0.23.0 and golang/glog 1.0.0 (GHSA-259r-337f-4rfw,
GHSA-5cv4-jp36-h3mw, GHSA-qxp5-gwg8-xv66, GHSA-vvgc-356p-c3xw,
GHSA-6wxm-mpqj-6jpf). They are raised to 1.18.7, 0.62.0 and 1.2.4, and
govulncheck then found more in x/net, x/text and OpenTelemetry, which are raised
too (x/net 0.62.0, x/text 0.43.0, otel 1.42.0): it now reports none. A test
holds every go.mod of the repository at or above these floors.

Thanks to dependabot and xet7 !
2026-11-04 00:12:45 +02:00
Lauri Ojansivu 5e07b8db6c Docs: the speed test, version history and embedded engines READMEs link directories, not README.md files. 2026-11-03 23:59:03 +02:00
Lauri Ojansivu eeeb0dbec9 CHANGELOG: Go databases compared with FerretDB's SQLite. 2026-11-03 23:47:33 +02:00
Lauri Ojansivu 96d56ecc6c Go databases that could be linked into FerretDB, against its SQLite: none is faster where it counts.
A benchmark of its own (docs/Databases/FerretDB/1/SpeedTest/embedded, a Go module,
no cgo) loads the speed test's cards into SQLite set up as FerretDB sets it and
into Pebble, Badger, bbolt, BuntDB, Nutsdb and Clover, and asks each the six
questions WeKan asks of cards, at 15,377 and 153,768 cards. A board's cards take
the same time on every engine that is not slower, because decoding the JSON
dominates; the key-value stores win single reads and writes by microseconds that
a FerretDB call hides; Pebble is 12x slower on fetching a board, Clover 90-190x
slower in total, and bbolt with its default fsync 100-160x slower on writes. The
README accounts for every entry of the awesome-go section.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 23:47:13 +02:00
Lauri Ojansivu aadfd9b6b3 CHANGELOG: Dolt in the speed test and the version history experiment. 2026-11-03 23:19:00 +02:00
Lauri Ojansivu 1c44876beb Speed test: Dolt, and what a database with version history would give WeKan's undo and redo.
Dolt runs under the existing MySQL handler with no new handler and gives the same
answers, but is by far the slowest: 20,470 ms in total, every card query a
2.8 s whole-table scan. The VersionHistory scripts drive WeKan-shaped changes
through FerretDB onto Dolt (a commit per write, undo and redo with DOLT_REVERT,
AS OF, +0.4 ms a write, a conflict when the same document is restored again) and
through the REST API of TerminusDB (author and message per commit, field-level
restore). ClickHouse and StarRocks were tried with the MySQL handler and need a
handler of their own; the README says where each fails.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 23:18:51 +02:00
Lauri Ojansivu bd86b518ea CHANGELOG and tests: the small-scale speed test, pgrust, TiDB and the databases that cannot run FerretDB. 2026-11-03 22:47:16 +02:00
Lauri Ojansivu f576a0cca9 Speed test: TiDB added, and the README says what was tried: pgrust, TiDB, Noria, YugabyteDB, CockroachDB.
TiDB (MySQL protocol, Apache-2.0) runs FerretDB after its metadata table's
generated columns became VIRTUAL, and gives the same answers as MongoDB; it is
a speed test row and opt-in for the conformance run (WEKAN_CONFORMANCE_TIDB=1).
Noria has no JSON type, triggers or generated columns, which the MySQL handler
needs; YugabyteDB refuses a CREATE INDEX on a jsonb expression, which every
FerretDB v1 PostgreSQL index is; CockroachDB is not open source. The README
also records that analyzing a PostgreSQL table after its indexes are created
took the PostgreSQL row from 454 to 145 ms, and that the same on MySQL and
MariaDB did nothing.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 22:45:56 +02:00
Lauri Ojansivu 46c0ad43e8 Speed test: small-scale results ordered fastest to slowest, with times slower, and pgrust.
Results.md now opens with an At a glance table (rank, total, how many times
slower than the fastest, the same for the slowest user), every table is ordered
by its own numbers, the header says the real data size and the real operating
system, and the load table is ordered by the whole load time. pgrust
(PostgreSQL in Rust, malisper/pgrust:v0.4-beta) is one more row through the
same PostgreSQL handler; in the conformance run it is opt-in with
WEKAN_CONFORMANCE_PGRUST=1. The run is 1/20 of a production WeKan so it takes
minutes.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 22:36:56 +02:00
Lauri Ojansivu 499772e4c2 Speed test README: sorting in PostgreSQL was tried and was not faster.
The ten newest of 30,116 users took 52 ms with an ORDER BY in the database and
38-49 ms decoded and sorted in Go, so the change was not kept; the README says
what was measured so it is not repeated.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 21:53:31 +02:00
Lauri Ojansivu 90fe133363 Merge remote-tracking branch 'origin/main' 2026-11-03 21:37:36 +02:00
Lauri Ojansivu b812d36871 CHANGELOG: the speed test README. 2026-11-03 21:24:06 +02:00
Lauri Ojansivu d05a42a5af Speed test README: the FerretDB v1.100-v1.105 and WeKan improvements since the last run.
SQLite sorts and stores JSONB, PostgreSQL uses a GIN index for membership and
pushes down $or, $elemMatch and $and, MySQL and MariaDB read through their
indexes with multikey side tables, and WeKan stopped scanning whole collections
every hour or at every start. Results.md still shows v1.99.0, which the README
now says, and the open items are updated to match.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 21:24:03 +02:00
github-actions[bot] 42507de1d0 Bump versions for v12.92 2026-11-03 18:51:08 +00:00
Lauri Ojansivu 131b0cc509 Prepare v12.92 release 2026-11-03 20:48:47 +02:00
Lauri Ojansivu 93ae5ac6ee Merge remote-tracking branch 'origin/main' 2026-11-03 20:46:48 +02:00
Lauri Ojansivu 34bb91d1f5 CHANGELOG: a missing REST checklist answers a JSON 404. 2026-11-03 20:43:21 +02:00
Lauri Ojansivu d231df56fc REST API: a missing checklist, or one under another card, answers a JSON 404, not an empty 500.
GET /api/boards/:boardId/cards/:cardId/checklists/:checklistId answered 500 with
no body when the checklist did not exist or belonged to another card. It now
answers 404 with the same error text the checklist update and delete routes
use; board access and the parent-card check are unchanged. Route tests drive
the registered handler (found, missing, other card, other board, denied, lookup
failure) and a browser spec reads the checklist over REST and after a reload.

Thanks to AhmedLukman and xet7 !
2026-11-03 20:43:06 +02:00
github-actions[bot] e422ab691e Bump versions for v12.91 2026-11-03 18:01:08 +00:00
Lauri Ojansivu 3f712a0445 Prepare v12.91 release 2026-11-03 19:59:16 +02:00
Lauri Ojansivu d99dadadee Merge remote-tracking branch 'origin/main' 2026-11-03 19:42:04 +02:00
Lauri Ojansivu a58b7bee2c CHANGELOG: code scanning alert 600, a test replacing getLabel with itself. 2026-11-03 19:17:12 +02:00
Lauri Ojansivu 62da564a1e Code scanning #600: a test no longer replaces getLabel with itself, and the guard knows anchored patterns.
tests/boardLabelLookup.test.cjs sliced the getLabel method out of models/boards.js
and then ran .replace(/^getLabel/, 'getLabel') on it, which does nothing: the
slice already starts at getLabel. tests/noIdentityReplacement.test.cjs skipped
every regular expression with an anchor, so it could not see it; a leading ^ or
a trailing $ over plain characters is now compared like any other.

Thanks to GitHub CodeQL and xet7 !
2026-11-03 19:16:44 +02:00
94 changed files with 6525 additions and 315 deletions

No files matched your search

+515
View File
@@ -124,6 +124,521 @@ its reason.
</details>
# Upcoming WeKan ® release
**In short:** **Continuous backup** now runs on one server at a time, with a
lease, when WeKan has
several servers. Missing-board **exports** and **integration updates** answer
404 instead of 200, a
card stops offering the attachment "+" when uploads are blocked, and the Docker
image's **nodemailer**,
**body-parser**, **qs**, **uuid** and **postcss-selector-parser** are raised
past the advisories a Grype
scan found.
This release fixes the following bugs:
**Exports** - what the export routes answer when something is wrong.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0c0d666ffa">Missing-board exports return 404, and an over-long export token returns 400</a>. Thanks to AhmedLukman and xet7.</summary>
The board Excel export answered a missing board with HTTP 200 and only the text
`Board not found` (PR #6869). The two PDF export routes had the same fault, and
the Excel and PDF routes answered an over-long auth token with 200 as well,
while `export.js` already used 404 and 400. All of them now choose their status
the same way.
`tests/exportErrorStatuses.test.cjs` scans every `models/export*.js` route for a
text answer without a status, so the fault cannot return anywhere there; the
PR's route and browser tests stay.
</details>
**Continuous backup** - which server streams it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/880ec2d707">Only one server streams it at a time, holding a lease</a>. Thanks to Raymond-Voogt and xet7.</summary>
With several WeKan servers on one database, every server applied the continuous
backup setting, so
each of them tailed the oplog and wrote its own archives into the same target
directory
(\#6861). The leader now holds a lease, one `continuousBackupLease` document
with its id and an
expiry. Taking it is one atomic operation, the leader renews it every 15 seconds
and it lapses after
45, so another server takes over within a minute when the leader stops, and a
server that loses the
lease stops its stream. Startup, saving the settings and a change saved on
another server converge
through that loop; a restore or a fetch from the cloud takes the lease and waits
one renewal so it
is the only writer. One server is always its own leader.
Checked against the real driver on FerretDB (SQLite): 24 clients at once give
exactly one leader, and
renewal, expiry, the old leader losing the lease and release behave.
`tests/continuousBackupLease.test.cjs`
pins the same and the wiring.
</details>
**Attachments** - what a card offers when the administrator blocked uploads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d8b3579117">A card no longer offers the attachment "+" when uploads are blocked for the whole site</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Attachments / Transfer limits already has "Attachments upload" and
"API upload" set to
Blocked, and the server refuses on every path: the upload pipeline,
`api.attachment.upload` and
`.copy`, and the `/api/attachment` upload, upload-background and copy routes.
The card still showed
the "+" button and accepted a drop or paste for an upload that would then be
refused. It now asks
the server one boolean, at most once a minute, and hides both. No second setting
was added
(\#6870 proposed a `disableAttachments`), so nothing can disagree with what the
server enforces.
`tests/attachmentUploadPolicy.test.cjs` pins the client, the method, the server
enforcement it relies
on, and that no `disableAttachments` setting exists.
</details>
**REST API** - what the integration routes answer for an integration that is not
there.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6012521ccc">An integration update for a missing or wrong-board integration returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
`PUT /api/boards/:boardId/integrations/:intId` answered 200 with the requested
id when no
integration matched that id and board (PR #6871). The two activities routes
(`POST` and `DELETE`
`.../activities`) updated nothing and answered 200 with `data: null`. All three
now look the
integration up on that board after board-admin authorization and answer the JSON
404 the `GET`
route already uses, before validating or writing anything.
`tests/restIntegrationWriteRoutes404.test.cjs` pins, for the whole file, that
every handler that
updates an integration looks it up first and only after authorization; the PR's
route and browser
tests stay.
</details>
and updates the following dependencies:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/dad03959d9">nodemailer 10.3.1, body-parser 1.20.9 and 2.4.0, qs 6.16.1, uuid 11.1.1 and postcss-selector-parser 6.1.5 in the bundle</a>. Thanks to the Grype scan reporter and xet7.</summary>
A Grype scan of the image found High and Medium advisories in packages that
Meteor's own packages carry inside `programs/server/npm/node_modules/meteor/`,
where `package.json` and its `overrides` cannot reach.
`releases/bump-bundle-npm-deps.mjs` raises them after `meteor build`.
- **nodemailer 8.0.11 to 10.3.1** (GHSA-2x7j-588g-ccc2, GHSA-v53p-9fqp-m79j,
GHSA-p6gq-j5cr-w38f and the rest fixed in 10.1.4 or later). It was listed as
not fixable because the fix is a new major. `meteor/email` only calls
`createTransport`, the `stream` plugin hook, `nodemailer/lib/well-known` and
`nodemailer/lib/mail-composer`, and all four were run against 10.3.1 through
the bundle's own package specifiers.
- **body-parser 1.20.6 and 1.20.8 to 1.20.9, and 2.3.0 to 2.4.0**
(GHSA-27r3-jxmh-p828). A minimum may now be written `body-parser@2`, which
applies to copies of that major only, so each major is raised inside itself.
- **qs to 6.16.1** (GHSA-6pq8-jhm2-2vcw) and **postcss-selector-parser 6.1.4 to
6.1.5** (GHSA-rj75-hqrm-r3gf).
- **uuid 8.3.2 and 9.0.1 to 11.1.1** (GHSA-w5hq-g745-h8pq). uuid 11 moved its
entry point from `dist/index.js` to `dist/cjs/index.js`, which killed the
server on boot the one time it was tried. The bumper now writes the old
`dist/index.js` back as `export * from './esm/index.js'`: it is an ES module
because uuid is `"type": "module"`, and Node 26 `require()`s it.
glob 14 and minimatch 11 were already in `package.json`; the scanned image
predates them. The runtime image installs no compiler (`gcc-14-base` is Debian's
one-file package), so the multi-stage build the report asks for is already how
it is built. Run against a copy of the published image's bundle, the bumper
raised all of the above and every raised copy loaded.
`tests/bundleNpmSecurityBumps.test.cjs` pins the minimums, the per-major
rule and the shim, and runs the bumper with `--dry-run` on a bundle of
body-parser, uuid and nodemailer copies at, below and above their minimums.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.94 2026-11-04 WeKan ® release
**In short:** On an iPad the **opened card covers the header bar** instead of
sitting
behind it, and the speed test measures where the time goes when a WeKan board is
opened, on FerretDB and on MongoDB.
This release fixes the following bugs:
**Opened cards** - where a card opens.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/efaf94242e">On a phone in desktop mode the opened card starts below the header bar</a>. Thanks to xet7.</summary>
The header wraps to several rows on a phone, far taller than the 8px the
floating card docks at, so the card's top row was among the header's rows and
was only seen by dragging it down. Below 801px a card without an inline top,
popup cards included, now starts at `--wekan-header-height` + 8px. A dragged
place is an inline top, so it is kept when the finger is lifted.
`tests/cardBelowHeaderOnPhone.test.cjs` pins the rule and that it never
matches a dragged card.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/006d24502c">The opened card covers the header bar on an iPad instead of sitting behind it</a>. Thanks to xet7.</summary>
On iPad Chrome (and Safari) the opened card's top row - the collapse caret, the
card
number, the menu, maximize and X - was behind the header bar, although the
card's
`z-index` is 2001 and the header's 1000. iOS WebKit makes every scrolling
element
(`#content`, the board wrapper) a stacking context of its own, so the card's
`z-index`
is compared only inside it, and the header bar, itself a stacking context, is
painted
over all of it. While a card is open the header bar is now at the level of the
content,
which comes after it in the page and paints over it; with no card open it keeps
its
own level. A browser spec makes the same trap with `isolation: isolate` on
`#content`
and the board wrapper and shows the card over the header, open and collapsed,
with its
close button reachable, in Chromium and WebKit; without the rule it fails as the
screenshot shows. `tests/cardOverHeaderStacking.test.cjs` pins the rule and its
selector. An iPad is the final check.
</details>
and has the following documentation change:
**Database speed test** - how fast the databases answer WeKan's queries.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/3621413d6f">The speed test measures where the time goes when a WeKan board is opened, on FerretDB and on MongoDB</a>. Thanks to Raymond-Voogt and xet7.</summary>
`docs/Databases/FerretDB/1/SpeedTest/app` opens a board of 200, 2,000 and 5,000
cards in
Chromium on WeKan with FerretDB (SQLite) and with MongoDB 8, and profiles the
Meteor
server (Node inspector) and FerretDB (pprof). Two runs each. The Meteor server
uses
0.1-0.5 s of CPU, the same on both, and its DDP server is 1% of that; the
browser gets
the same ~210 KB at every size. A 5,000-card board takes FerretDB 400-600 ms
longer to
open (first card +42-48%), a small board only 70-100 ms, and FerretDB spends
about 4 s
of CPU on it, 39% in the SQLite engine and 34% filtering in Go what it read. So
a
backend rewritten in another language would save part of a fraction of a second,
while
the part that scales with the board is the database layer. The README says what
was not
measured: a production build, the browser's own time, and which query sends the
`aggregate`. `tests/boardOpenProfile.test.cjs` pins the analysis of the profile
and what
the README claims.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.93 2026-11-04 WeKan ® release
**In short:** The database **speed test** has a new small-scale result, ordered
from fastest to slowest with how many times slower each database is, and two
more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
and CockroachDB were looked at and cannot be used, for reasons the README gives.
This release adds the following new features:
**Database speed test** - how fast the databases answer WeKan's queries.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/46c0ad43e8">Results.md is a quick small-scale run, fastest first, with how many times slower each is</a>. Thanks to Raymond-Voogt and xet7.</summary>
The speed test was only ever run at twice a production WeKan, which takes hours.
`WEKAN_SPEEDTEST_SCALE=0.05` is 1/20 of one and the whole run takes under half
an
hour. Results.md opens with an **At a glance** table: rank, the total of all
queries, and **how many times slower** than the fastest database, for the
average
user and for the slowest one (the biggest board or the busiest user). Every
table
is ordered by its own numbers, so the slowest-user table can rank differently,
the
loading table is ordered by the whole load time, and the header says the real
data
size and the real operating system (it said "twice a production WeKan" and
"Linux" whatever was measured). `tests/dbSpeedTest.test.cjs` pins the ranks, the
ratios, the per-table order and the header, with a negative test for each.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/46c0ad43e8">The speed test includes pgrust, PostgreSQL rewritten in Rust</a>. Thanks to Raymond-Voogt and xet7.</summary>
[pgrust](https://github.com/malisper/pgrust) v0.4-beta is wire compatible with
PostgreSQL 18, so FerretDB's PostgreSQL handler uses it unchanged, and it gives
the
same answers as MongoDB. It is **not faster** than PostgreSQL for WeKan's
queries
(174 ms against 145 ms in total), and creating WeKan's 47 indexes takes 38 s
against 1 s. In the conformance run it is opt-in (`WEKAN_CONFORMANCE_PGRUST=1`).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f576a0cca9">The speed test includes TiDB, and the README says which other databases cannot run FerretDB and why</a>. Thanks to Raymond-Voogt and xet7.</summary>
TiDB 7.5 (Apache-2.0, MySQL protocol, one process on its own storage) runs
FerretDB's MySQL handler and gives the same answers as MongoDB, after one change
in FerretDB: its metadata table's generated columns are `VIRTUAL`, because TiDB
refuses `ALTER TABLE ... ADD COLUMN ... STORED`. Opt-in in the conformance run
(`WEKAN_CONFORMANCE_TIDB=1`). Noria has no JSON type, triggers or generated
columns, which the MySQL handler needs; YugabyteDB refuses a `CREATE INDEX` on a
`jsonb` expression, which every FerretDB v1 PostgreSQL index is; CockroachDB is
under a proprietary licence. The README's **Other databases tried** has the
reasons.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1c44876beb">The speed test includes Dolt, and VersionHistory tests what version history would give undo and redo</a>. Thanks to Raymond-Voogt and xet7.</summary>
[Dolt](https://github.com/dolthub/dolt) (git for data, MySQL protocol) runs under
FerretDB's existing MySQL handler and gives the same answers, but is by far the
slowest: 20,470 ms in total, 1,230x MongoDB, because every query over cards is a
2.8 s whole-table scan. `docs/Databases/FerretDB/1/VersionHistory` drives
WeKan-shaped changes through FerretDB onto Dolt and through the REST API of
TerminusDB. Dolt makes a commit per FerretDB write for +0.4 ms, undoes and
redoes
with `DOLT_REVERT` and reads any earlier state with `AS OF`, but names the SQL
user and not the WeKan user, and cannot restore one field of a document that
changed again (a conflict). TerminusDB records the author and message of every
commit and restores one field while later changes stay, but speaks neither the
MongoDB protocol nor SQL. WeKan's own History remains the undo; Dolt would be a
backup and audit layer under it. ClickHouse and StarRocks were tried with the
MySQL handler and need a handler of their own, which has not been written.
`tests/versionHistoryExperiment.test.cjs` and `tests/dbSpeedTest.test.cjs` pin
the
scripts and the findings.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/96d56ecc6c">Go databases that could be linked into FerretDB were compared with its SQLite: none is faster where it counts</a>. Thanks to Raymond-Voogt and xet7.</summary>
`docs/Databases/FerretDB/1/SpeedTest/embedded` is a Go module of its own (pure
Go,
no cgo) that loads the speed test's cards into SQLite set up as FerretDB sets it
and into Pebble, Badger, bbolt, BuntDB, Nutsdb and Clover, and asks each the
questions WeKan asks of cards, at 15,377 and 153,768 cards. Every engine must
give
the right counts first. A board's cards take the same time on every engine that
is
not slower, because decoding the JSON dominates; the key-value stores win single
reads and writes by microseconds that a FerretDB call hides, and would make the
handler do in Go what SQL does in SQLite. Pebble is 12x slower on fetching a
board, Clover 90-190x slower in total, and bbolt with its default fsync 100-160x
slower on writes. The README accounts for every entry of the awesome-go
databases section. `tests/embeddedEnginesBench.test.cjs` pins the export, the
engines, the SQLite setup and the conclusion.
</details>
**The header bar** - what sits beside the logo.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f25957dcf3">A full screen button to the right of the logo in the header bar</a>. Thanks to xet7.</summary>
A button puts the whole page in full screen and takes it out again with the
Fullscreen API, in iPad Safari, desktop browsers and Android browsers. Its label
is the existing Maximize, so no language file changes. It is drawn only where
the
browser offers the API and the page may use it: iPhone Safari does not have it
for pages (use Add to Home Screen, the manifest says `display: standalone`), and
neither does a page in an iframe without `allowfullscreen`. The icon follows the
browser's own events, so leaving with Esc is understood.
`tests/fullscreenButton.test.cjs`
drives the pure part with fake documents, including a refusal, and
`tests/playwright/specs/header-fullscreen.e2e.js` checks the position, the
label,
entering, leaving, Esc and the negative case in Chromium and WebKit.
</details>
and updates the following dependencies:
- **minimatch 10.2.6 → 11.0.4** in the `npm-packages/exceljs` and vendored
`jade` lockfiles ([GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249),
Dependabot alerts \#180 and \#182), through overrides on `readdir-glob`,
`glob` 13 and
`test-exclude`, as the root lockfile has; a test reads every
`package-lock.json`.
- **klauspost/compress 1.18.0 → 1.18.7**, **golang.org/x/net 0.23.0 → 0.62.0**,
**golang.org/x/text 0.14.0 → 0.43.0**, **golang/glog 1.0.0 → 1.2.4** and
**OpenTelemetry 1.41.0 → 1.42.0** in the Go module of the embedded-engine
benchmark (Dependabot alerts \#183-\#187; `govulncheck` reports none). A test
holds
every `go.mod` at or above these floors.
Thanks to dependabot.
and fixes the following bugs:
**Opened cards** - where a card opens.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/84b0480aa8">A card opened as a popup covers the header bars instead of opening below them</a>. Thanks to xet7.</summary>
On an iPad, a card opened from the Roadmap view (and from search results and the
table view) opened below the top header, and collapsed it was a bar floating
over
the swimlane. A card opened as a popup got the generic wide-layout rule
`top: 97px` and `z-index: 30`, because the desktop-mode rules that dock an
opened
card at the top skip a popup, while the popup wrapper rule in the same file says
it must cover the header bars. It now docks at `top: 8px` with `z-index: 2001`
in
the wide layout; mobile mode keeps its full-window rule. This was reproduced in
Chromium and in WebKit with an iPad profile; whether it is the whole of what an
iPad shows is for the iPad to say.
`tests/cardPopupOverHeader.test.cjs` pins the rule and
`tests/playwright/specs/card-popup-over-header.e2e.js` shows the card over the
header, open and collapsed, and fails without the fix (top 97 against a header
88
high).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1b05673200">The opened card and every popup move under a finger, on an iPad</a>. Thanks to xet7.</summary>
On an iPad the opened card did not move when dragged from the collapse caret,
the
space beside it, the card number, or the drag handle, while the page behind it
scrolled. The card window was moved by mouse events only, which a finger does
not
send while it moves, and its drag areas did not say `touch-action: none`, so the
browser took the gesture for a scroll. It is moved by Pointer Events now: the
header row or the handle starts the drag; a link or button in the row (the
caret,
close, the menu) still does its job when tapped and moves the window when
dragged,
without also collapsing or closing; the title still edits when tapped; and with
drag handles on only the handle moves it. The card number is above the title's
edit
zone, so it drags too. The window is kept within reach, and `touch-action: none`
is
on the card header, its handle and every popup's title bar and handle, so popups
move by finger as well.
`tests/windowDragTouch.test.cjs` pins the decisions and the wiring, and
`tests/playwright/specs/card-window-touch-drag.e2e.js` drags with a real touch
from
each place with handles on and off, taps the caret, drags with a mouse and moves
a
popup; the finger tests fail without the fix. Tested in Chromium; an iPad is the
final check.
</details>
and has the following documentation change:
**Database speed test** - what was improved, and what was tried and was not.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d05a42a5af">The speed test README lists the improvements since the last run, and what did not help</a>. Thanks to Raymond-Voogt and xet7.</summary>
SQLite sorts in the database and stores JSONB, PostgreSQL answers membership
from a GIN index and pushes down `$or`, `$elemMatch` and `$and`, MySQL and
MariaDB read through their indexes with multikey side tables, and WeKan no
longer scans whole collections every hour or at every start of every server. The
small run found one more: **PostgreSQL planned from no statistics** until
autovacuum analyzed a freshly indexed table, so a Due Cards query took 407 ms
and
84 ms after; FerretDB now analyzes the table when its indexes are created (the
PostgreSQL row went from 454 to 145 ms, and from last place to eighth). A
profile of Due Cards on SQLite found another: a membership test made SQLite turn
the whole `members` array into JSON text and parse it again for every row, and
reading it from the column instead cut the visible-boards query from 26 to 16 ms
(All Boards 6.9 → 4.9 and 5.9 → 3.9 ms, Due Cards 54.5 → 43.9 ms, the SQLite
total 82.7 → 68.3 ms) with identical answers. Tried and
not kept, with the numbers: sorting in the database on PostgreSQL (52 ms against
38-49 ms decoded in Go) and `ANALYZE TABLE` on MySQL and MariaDB (no change).
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.92 2026-11-03 WeKan ® release
**In short:** A missing **REST checklist** answers a JSON 404 instead of an
empty 500.
This release fixes the following bugs:
**REST API** - answers for things that are not there.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d231df56fc">A missing checklist, or one under another card, answers a JSON 404 instead of an empty 500</a>. Thanks to AhmedLukman and xet7.</summary>
[\#6867](https://github.com/wekan/wekan/pull/6867). `GET
/api/boards/:boardId/cards/:cardId/checklists/:checklistId` answered `500` with
no body when the checklist did not exist or belonged to another card. It now
answers `404` with `Checklist not found or does not belong to the specified
card`, the text the update and delete routes already use; the board-access check
and the parent-card check are unchanged. `tests/restChecklistGet.test.cjs`
drives
the registered handler (found, missing, another card, another board, access
denied, and a failing lookup that must not become a false 404) and
`tests/playwright/specs/rest-checklist-not-found.e2e.js` reads the checklist
over REST and after a page reload.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.91 2026-11-03 WeKan ® release
**In short:** A **GitHub CodeQL** finding in a test is fixed, and the guard
against
replacing text with itself now also sees patterns with a `^` or `$` anchor.
This release has the following developer-tooling fix:
**Code scanning** - the tests that guard the tree.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/62da564a1e">A test no longer replaces getLabel with itself, and the identity-replacement guard knows anchored patterns</a>. Thanks to GitHub CodeQL and xet7.</summary>
Code scanning alert #600 (`js/identity-replacement`, CWE-116):
`tests/boardLabelLookup.test.cjs` cut the `getLabel` method out of
`models/boards.js` and then ran `.replace(/^getLabel/, 'getLabel')` on it, which
does nothing because the slice already starts at `getLabel`. The line is gone.
`tests/noIdentityReplacement.test.cjs` skipped every regular expression with an
anchor, so it could not see the shape; a leading `^` or a trailing `$` over
plain characters is now compared like any other, with samples of both for the
guard to detect and the existing samples that must not be reported.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.90 2026-11-03 WeKan ® release
**In short:** The login page can have its own **background color**, Member
+47 -31
View File
@@ -5,6 +5,53 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# Upcoming WeKan ® release
**Continuous backup** now runs on one server at a time, with a
lease, when WeKan has
several servers. Missing-board **exports** and **integration updates** answer
404 instead of 200, a
card stops offering the attachment "+" when uploads are blocked, and the Docker
image's **nodemailer**,
**body-parser**, **qs**, **uuid** and **postcss-selector-parser** are raised
past the advisories a Grype
scan found.
[All changes so far](CHANGELOG-big.md#upcoming-wekan--release)
# v12.94 2026-11-04 WeKan ® release
On an iPad the **opened card covers the header bar** instead of
sitting
behind it, and the speed test measures where the time goes when a WeKan board is
opened, on FerretDB and on MongoDB.
[All changes in v12.94](CHANGELOG-big.md#v1294-2026-11-04-wekan--release)
# v12.93 2026-11-04 WeKan ® release
The database **speed test** has a new small-scale result, ordered
from fastest to slowest with how many times slower each database is, and two
more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
and CockroachDB were looked at and cannot be used, for reasons the README gives.
[All changes in v12.93](CHANGELOG-big.md#v1293-2026-11-04-wekan--release)
# v12.92 2026-11-03 WeKan ® release
A missing **REST checklist** answers a JSON 404 instead of an
empty 500.
[All changes in v12.92](CHANGELOG-big.md#v1292-2026-11-03-wekan--release)
# v12.91 2026-11-03 WeKan ® release
A **GitHub CodeQL** finding in a test is fixed, and the guard
against
replacing text with itself now also sees patterns with a `^` or `$` anchor.
[All changes in v12.91](CHANGELOG-big.md#v1291-2026-11-03-wekan--release)
# v12.90 2026-11-03 WeKan ® release
The login page can have its own **background color**, Member
@@ -14,34 +61,3 @@ and
**Escape closes the add-list composer**.
[All changes in v12.90](CHANGELOG-big.md#v1290-2026-11-03-wekan--release)
# v12.89 2026-11-03 WeKan ® release
Keyboard **shortcuts run once on a non-Latin keyboard**: `d` no
longer opens the Due date popup and closes it again on a Farsi layout.
[All changes in v12.89](CHANGELOG-big.md#v1289-2026-11-03-wekan--release)
# v12.88 2026-11-03 WeKan ® release
**Every board theme** and **every color a member chooses** is
readable: the dark themes' **Card Settings**, popups and sidebars, the header of
eight themes, and a custom accent, text or label color made legible. A padded
**REST label** name no longer creates a second label.
[All changes in v12.88](CHANGELOG-big.md#v1288-2026-11-03-wekan--release)
# v12.87 2026-11-03 WeKan ® release
A **REST API** "get one" that finds nothing answers **404**
instead of 200 with an empty body.
[All changes in v12.87](CHANGELOG-big.md#v1287-2026-11-03-wekan--release)
# v12.86 2026-11-02 WeKan ® release
The daily **notification cleanup** and the **file integrity
audit**
run once for the whole installation, not once per server.
[All changes in v12.86](CHANGELOG-big.md#v1286-2026-11-02-wekan--release)
+1 -1
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=12.90
ARG VERSION=12.94
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v12.90.0"
appVersion: "v12.94.0"
files:
userUploads:
- README.md
+1 -1
View File
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
.attachment-gallery
//- #6796: a small "+" like every other section's, not a full-width box.
if canModifyCard
if canAddAttachment
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
i.fa.fa-plus
+8
View File
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
import { formatDateTime } from '/imports/lib/dateUtils';
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
});
Template.attachmentGallery.helpers({
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
canAddAttachment() {
return Utils.canModifyCard() && !attachmentUploadsBlocked();
},
// #6796: the small file button instead of the preview tile
// (models/lib/attachmentTile.cjs).
attachmentTileCompact(file) {
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
return [];
}
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
if (attachmentUploadsBlocked()) return [];
// Check if board allows attachments
const board = card.board();
if (!board || !board.allowsAttachments) {
+48
View File
@@ -185,6 +185,23 @@ body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-colla
bottom: 8px;
}
/* A phone in desktop mode: the header bar wraps to several rows (up to five with
the board controls open), far taller than the 8px the floating window docks
at, so the card's own top row (caret, number, menu, X) sat among the header's
rows and was only seen by dragging the card down. Below 801px the card starts
BELOW the header, at the height Utils publishes as --wekan-header-height.
Only a card WITHOUT an inline top matches (`:not([style*="top"])`), so the
place a finger dragged it to is an inline top and is kept when the finger is
lifted - this rule never competes with it. */
@media screen and (max-width: 800px) {
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]),
/* a card opened as a popup (search result, table, Roadmap bar) docks the same way */
body.desktop-mode .card-details.card-details-popup:not([style*="top"]),
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
top: calc(var(--wekan-header-height, 0px) + 8px) !important;
}
}
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
with viewport insets), never laid out inside — or measured from — the
scrolled swimlane/board container. Historically the maximized pane was an
@@ -250,6 +267,24 @@ body.desktop-mode .card-details.card-details-collapsed {
color: #b3b3b3;
display: inline-block;
margin-inline-end: 6px;
/* Above the title's edit zone (z-index 1, over the whole title): a press on the
card number moves the window like the rest of the header row; only the title
itself edits. */
position: relative;
z-index: 2;
}
/* The areas that move the window by a finger. Without touch-action: none the browser
takes a finger drag for a scroll and cancels the pointer, so the window never moves
(the page behind it scrolls instead). Only in desktop mode, where the card is a
window; the full-screen card of mobile mode is not moved. */
body.desktop-mode .card-details .card-details-header,
body.desktop-mode .card-details .card-details-header .card-drag-handle {
touch-action: none;
}
body.card-window-dragging {
user-select: none;
-webkit-user-select: none;
}
/* Collapse toggle triangle */
@@ -536,6 +571,19 @@ body.desktop-mode .card-details.card-details-collapsed {
resize: both;
}
/* A card opened as a POPUP (Session popupCardId: from a search result, a table
or a Roadmap/Gantt bar) docks at the very top of the window like every other
opened card, and covers the header bars, as the .pop-over[data-popup=
"cardDetailsPopup"] rule near the end of this file says it must. The 97px
above is the room the header takes for a card that is NOT docked by the
desktop-mode rules (those skip popups), and it opened the popup BELOW the
header: collapsed, it was a bar floating over the swimlane. Mobile mode keeps
its own full-window rule (top: 0 !important). */
.card-details.card-details-popup {
top: 8px;
z-index: 2001;
}
/* Override for mobile mode even on larger screens */
body.mobile-mode .card-details {
width: 100vw !important;
+85 -79
View File
@@ -81,6 +81,7 @@ const editingLocationId = new ReactiveVar(null);
// Shared with Admin Panel, which links the places accounts log in from the same
// way - models/lib/mapLink.js.
const { mapLinkFor, GCJ } = require('/models/lib/mapLink');
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('/models/lib/windowDrag');
@@ -1192,6 +1193,66 @@ Template.cardSectionHeader.events({
},
});
// Moves the card window under a finger or a mouse. `press` is cardHeaderPress's answer.
// A press on a control waits until the pointer has really moved, so a tap still
// collapses, closes or opens the menu; once it moved, the click that follows the drag
// is swallowed.
function beginCardWindowDrag(jqEvent, press) {
// Blaze hands over a jQuery event, which does not copy `isPrimary`; the browser's
// own event has it.
const event = jqEvent.originalEvent || jqEvent;
if (!press.start || !event.isPrimary) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
const $card = $(event.target).closest('.card-details');
if (!$card.length) return;
const startX = event.clientX;
const startY = event.clientY;
const offset = $card.offset();
const startLeft = offset.left - window.scrollX;
const startTop = offset.top - window.scrollY;
const threshold = dragThreshold(event.pointerType, press.deferred);
const pointerId = event.pointerId;
let moving = false;
const onMove = (e) => {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moving) {
if (!passedThreshold(dx, dy, threshold)) return;
moving = true;
// #6465: where the user put it wins over where the minicard is, so a later
// resize re-places every other window but only clamps this one into view.
markCardDetailsUserMoved($card);
document.body.classList.add('card-window-dragging');
}
e.preventDefault();
const placed = clampWindow(startLeft + dx, startTop + dy, $card.outerWidth(),
{ width: window.innerWidth, height: window.innerHeight });
$card.css({ left: `${placed.left}px`, top: `${placed.top}px` });
};
const onEnd = (e) => {
if (e.pointerId !== pointerId) return;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onEnd);
document.removeEventListener('pointercancel', onEnd);
document.body.classList.remove('card-window-dragging');
if (moving && press.deferred) {
// The drag started on a link or a button: its click is not a tap.
const swallow = (click) => { click.preventDefault(); click.stopPropagation(); };
document.addEventListener('click', swallow, { capture: true, once: true });
setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400);
}
};
document.addEventListener('pointermove', onMove, { passive: false });
document.addEventListener('pointerup', onEnd);
document.addEventListener('pointercancel', onEnd);
}
Template.cardDetails.events({
// #1591 folded each FIELD by its own title, storing a class per card in the
// profile. The section carets replaced it: one handle per section, on the
@@ -1221,86 +1282,31 @@ Template.cardDetails.events({
Users.setPublicCardCollapsed(!currentState);
}
},
'mousedown .js-card-drag-handle'(event) {
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// #6465: where the user put it wins over where the minicard is, so a later
// resize re-places every other window but only clamps this one into view.
markCardDetailsUserMoved($card);
const startX = event.clientX;
const startY = event.clientY;
const startLeft = $card.offset().left;
const startTop = $card.offset().top;
const onMouseMove = (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
$card.css({
left: startLeft + deltaX + 'px',
top: startTop + deltaY + 'px'
});
};
const onMouseUp = () => {
$(document).off('mousemove', onMouseMove);
$(document).off('mouseup', onMouseUp);
};
$(document).on('mousemove', onMouseMove);
$(document).on('mouseup', onMouseUp);
// The opened card's window is moved by a finger as well as a mouse, so these are
// Pointer Events (a finger sends no mouse events while it moves, which left the card
// fixed on an iPad while the page behind it scrolled) and the areas that start a
// drag are `touch-action: none` (cardDetails.css). With drag handles ON the handle
// is the only thing that moves the window; with them off the header row moves it,
// except the title (it edits). A link or button in the row - the collapse caret, the
// menu, the card number's neighbours - still does its job when tapped, and moves the
// window when dragged. models/lib/windowDrag.js
'pointerdown .js-card-drag-handle'(event) {
beginCardWindowDrag(event, { start: true, deferred: false });
},
'mousedown .js-card-title-drag-handle'(event) {
// The title bar drags the window. Don't interfere with text selection.
if (
$(event.target).closest(
'a, input, textarea, button, select, option, .js-card-details-title',
).length > 0
) {
// #6641: while the editor replaces the header, a textarea press still
// bubbles to this drag bar. Leave it to the browser so mouse dragging
// selects text instead of moving the card window.
return;
}
// #6839: ...and never from the title, which edits - all of it
// (cardDetails.css). The window is moved from the empty space of the
// header row above it, beside the collapse caret and the menu.
if ($(event.target).closest('.card-details-title-edit-zone').length > 0) {
return;
}
// The drag handle has its own handler; without this both would run and the
// window would be moved twice by one press. And when there IS a handle -
// drag handles on - it is the only thing that moves the window, which is
// what turning handles on means.
if ($(event.target).closest('.js-card-drag-handle').length > 0
|| $(event.target).closest('.card-details').find('.js-card-drag-handle').length > 0) {
return;
}
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// #6465: same as the drag handle above — a dragged window keeps its place.
markCardDetailsUserMoved($card);
const startX = event.clientX;
const startY = event.clientY;
const startLeft = $card.offset().left;
const startTop = $card.offset().top;
const onMouseMove = (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
$card.css({
left: startLeft + deltaX + 'px',
top: startTop + deltaY + 'px'
});
};
const onMouseUp = () => {
$(document).off('mousemove', onMouseMove);
$(document).off('mouseup', onMouseUp);
};
$(document).on('mousemove', onMouseMove);
$(document).on('mouseup', onMouseUp);
'pointerdown .js-card-title-drag-handle'(event) {
const target = event.target;
const closest = selector => (target.closest ? target.closest(selector) : null);
const press = cardHeaderPress({
onEditable: !!closest('input, textarea, select, option, .js-card-details-title'),
onTitleEditZone: !!closest('.card-details-title-edit-zone'),
// The handle has its own handler above; this one steps aside for it.
onHandle: false,
cardHasHandle: !!closest('.js-card-drag-handle')
|| !!(closest('.card-details') && closest('.card-details').querySelector('.js-card-drag-handle')),
onControl: !!closest('a, button'),
});
if (closest('.js-card-drag-handle')) return;
beginCardWindowDrag(event, press);
},
'click .js-close-card-details'(event) {
event.preventDefault();
+12
View File
@@ -20,6 +20,18 @@
background: #2573a7;
z-index: 17;
}
/* An opened card covers the header bars, in every browser. iOS WebKit - iPad Safari and
iPad Chrome, which is WebKit too - makes every SCROLLING element (#content, the board
wrapper) a stacking context of its own. The card is inside them, so its z-index (2001)
is only compared there, while the header bar (#header-quick-access, z-index 1000, a
flex item of body and so a stacking context itself) is painted over all of it: the
card's top row - the collapse caret, the card number, the menu, maximize and X - sat
BEHIND the header bar. While a card is open the header drops to the level of the
content, which comes after it in the page and so paints over it; a browser without that
trap shows no difference. */
body:has(.card-details) #header-quick-access {
z-index: 0;
}
#header #header-main-bar {
/* Flex row (was float-based) so the right button group — which ends with the
sidebar hamburger — is always pinned to the right edge at every window width,
+13
View File
@@ -69,6 +69,19 @@ template(name="header")
div.header-logo
img(src="{{pathFor '/logo-header.png'}}" alt="{{headerLogoAlt}}" title="{{headerLogoAlt}}")
//- Full screen, to the right of the logo (iPad Safari, desktop and Android
browsers). Only drawn where the browser has the Fullscreen API and the page
may use it: not on iPhone, not in an iframe without allowfullscreen. The
label is the existing "Maximize" one. It stays before the first
.js-header-collapsible-icon, so "hide the icons" does not hide it.
models/lib/fullscreen.js
if fullscreenSupported
a.board-header-btn.js-toggle-fullscreen(href="#" role="button" title="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-label="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-pressed="{{#if isFullscreen}}true{{else}}false{{/if}}")
if isFullscreen
i.fa.fa-compress
else
i.fa.fa-expand
// Mobile/desktop mode toggle (swapped with zoom)
// #6680 follow-up: .js-header-collapsible-icon marks every icon the new
// .js-toggle-header-icons-collapsed button (beside Home) hides as a
+32
View File
@@ -59,6 +59,7 @@ function customPageTitle(routeName) {
// Drag-to-scroll on the two top header bars (they are not scroll containers
// themselves, so the drag is forwarded to the board canvas / page scroller).
import '/client/lib/headerDragscroll';
const fullscreen = require('/models/lib/fullscreen');
Meteor.subscribe('user-admin');
Meteor.subscribe('boards');
@@ -70,6 +71,10 @@ Meteor.subscribe('setting');
Meteor.subscribe('announcements');
Template.header.onCreated(function () {
const templateInstance = this;
// Full screen: whether the browser offers it here, and whether the page is in it.
// Both are set when the header is drawn (onRendered), where there is a document.
templateInstance.fullscreenSupported = new ReactiveVar(false);
templateInstance.isFullscreen = new ReactiveVar(false);
templateInstance.currentSetting = new ReactiveVar();
templateInstance.isLoading = new ReactiveVar(false);
templateInstance.subscribe('setting');
@@ -83,6 +88,14 @@ Template.header.onCreated(function () {
});
Template.header.onRendered(function () {
// The Fullscreen API, where it exists and the page may use it (models/lib/fullscreen.js).
// The state follows the browser's own events, so leaving with Esc, or with the
// browser's own control, changes the icon too.
this.fullscreenSupported.set(fullscreen.isSupported(document));
this.isFullscreen.set(fullscreen.isFullscreen(document));
this.onFullscreenChange = () => this.isFullscreen.set(fullscreen.isFullscreen(document));
fullscreen.CHANGE_EVENTS.forEach(name => document.addEventListener(name, this.onFullscreenChange));
this.autorun(() => {
Meteor.userId();
FlowRouter.watchPathChange();
@@ -92,7 +105,19 @@ Template.header.onRendered(function () {
});
});
Template.header.onDestroyed(function () {
if (this.onFullscreenChange) {
fullscreen.CHANGE_EVENTS.forEach(name => document.removeEventListener(name, this.onFullscreenChange));
}
});
Template.header.helpers({
fullscreenSupported() {
return Template.instance().fullscreenSupported.get();
},
isFullscreen() {
return Template.instance().isFullscreen.get();
},
logoSettingsReady() {
return Template.instance().subscriptionsReady();
},
@@ -365,6 +390,13 @@ Template.header.helpers({
});
Template.header.events({
// Full screen on and off. The request is made inside this click, which is the
// user gesture browsers require; a refusal is not an error (models/lib/fullscreen.js).
'click .js-toggle-fullscreen'(event) {
event.preventDefault();
fullscreen.toggle(document);
},
// The starred boards, by name. They were listed inline in this bar; the bar
// is one row now and the names are in the popup. docs/Features/Header-Bar/Header.md
// Titled "Starred Boards", from the key the app already has for that phrase,
+5
View File
@@ -39,6 +39,10 @@
first row of a popup, and it must match the space beside it: `.content` has 18px of
side padding, so 6 + 12 = 18 makes the top gap and the left gutter the same. */
.pop-over .header {
/* A popup is moved by its title bar (popup.js, Pointer Events). Without this a
finger drag on it is taken for a scroll and the pointer is cancelled, so only a
mouse could move it. */
touch-action: none;
height: calc(41px * var(--wekan-ui-font-scale, 1));
position: relative;
margin-bottom: 6px;
@@ -1441,6 +1445,7 @@ body.mobile-mode .pop-over .content-wrapper {
button (popup.js MOVABLE_POPUPS / MAXIMIZABLE_POPUPS), placed like the opened
card's: the handle before the title, the button beside the close button. */
.pop-over .header .popup-drag-handle {
touch-action: none;
float: inline-start;
padding: 12px 0 12px 12px;
cursor: move;
+23
View File
@@ -0,0 +1,23 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
// #6870: whether the administrator blocked attachment uploads for the whole site
// (models/lib/attachmentUploadPolicy.js). Asked of the server once a minute at most; until it
// answers - or if it cannot - uploads are shown as before, because the server refuses them either
// way and a wrong guess here only costs a button.
const blocked = new ReactiveVar(false);
let askedAt = 0;
export function refreshUploadPolicy() {
if (Date.now() - askedAt < 60 * 1000) return;
askedAt = Date.now();
Meteor.callAsync('attachmentUploadPolicy')
.then(policy => blocked.set(!!(policy && policy.uploadsBlocked === true)))
.catch(() => { askedAt = 0; });
}
// reactive
export function attachmentUploadsBlocked() {
refreshUploadPolicy();
return blocked.get();
}
+22 -10
View File
@@ -20,17 +20,29 @@ only what changes: documents, files and log lines. It writes them seconds after
they happen, to a directory that can be another disk, an NFS share or an
[rclone mount](Rclone/Rclone.md).
## One server only, for now
## Several servers: one of them streams
Continuous backup is for installations with **one WeKan server**. When it is
switched on in the Admin Panel, every WeKan server that shares the database
applies the setting, so with several servers (for example the replicas of a
Kubernetes Deployment) each of them tails the oplog and writes its own archives
into the same target directory: the same backup stream, many times, into one
place. It has no leader: one server should run the stream and another take it
over when that one stops (a lease), and that is not built yet
([#6861](https://github.com/wekan/wekan/issues/6861)). With several servers, use
the database's own backup instead.
When WeKan runs as several servers on one database (for example the replicas of a Kubernetes
Deployment), **one of them runs the stream** and the others stand by
([#6861](https://github.com/wekan/wekan/issues/6861)). They coordinate through a *lease*: one
document, `continuousBackupLease`, holding the leader's id and an expiry.
- Every server with the feature switched on tries to take the lease. Taking it is one atomic
database operation, so two servers can never both lead.
- The leader renews it every 15 seconds. It lapses 45 seconds after the last renewal, so when the
leader stops (a pod restart, a rollout, a crash) another server takes over within about a minute
and continues from the position the stream saved in the target.
- A server that finds it has lost the lease stops its stream, so two never write one target.
- Saving the settings, switching the feature off and on, and a change saved on another server all
converge through the same loop: the leader follows the change on its next tick.
- A restore, and *fetch from the cloud*, must be the only writer: they take the lease from whoever
holds it and wait one renewal, so the former leader has stopped before anything is written. The
stream resumes through the loop afterwards.
An installation with one server is always its own leader, so nothing changes for it. The target
directory is still one place: with several servers it must be on storage they all reach (a shared
volume, NFS, an [rclone mount](Rclone/Rclone.md)), or the server that takes over starts a new
generation instead of continuing.
## Licences: only copyfree code by default
+220 -18
View File
@@ -1,3 +1,13 @@
The full-scale tables below were measured on 2026-10-23 with FerretDB v1.99.0.
Every change in this section came after it (FerretDB v1.100.0 to v1.105.0,
2026-10-24 to 2026-10-31, WeKan up to 2026-11-02), so **those tables do not show
them**; [Results.md](Results.md) is a new, small-scale run that does. The
figures quoted in this section are the ones in FerretDB's own
[CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md), each
measured on a smaller database (61,507 cards, 92,000 boards or the 615,070 cards
of this test, as stated), so they show the size of a change, not a place in the
table.
# WeKan database speed test
How fast does WeKan's database answer the queries WeKan actually makes - on
@@ -24,6 +34,9 @@ exist, which images they use or how they are started:
| FerretDB v1 + PostgreSQL | `postgres:18`, the image of [docker-compose-ferretdb-v1-postgresql.yml](../../../../../docker-compose-ferretdb-v1-postgresql.yml) |
| FerretDB v1 + MySQL | `mysql:9`, from [docker-compose-ferretdb-v1-mysql.yml](../../../../../docker-compose-ferretdb-v1-mysql.yml) |
| FerretDB v1 + MariaDB | `mariadb:12`, from [docker-compose-ferretdb-v1-mariadb.yml](../../../../../docker-compose-ferretdb-v1-mariadb.yml) |
| FerretDB v1 + pgrust | `malisper/pgrust:v0.4-beta`: [PostgreSQL rewritten in Rust](https://github.com/malisper/pgrust), wire compatible, so FerretDB's PostgreSQL handler talks to it unchanged. In the conformance run only with `WEKAN_CONFORMANCE_PGRUST=1` |
| FerretDB v1 + TiDB | `pingcap/tidb:latest` (Apache-2.0, MySQL protocol) as one process on its own storage, no PD or TiKV. In the conformance run only with `WEKAN_CONFORMANCE_TIDB=1` |
| FerretDB v1 + Dolt | `dolthub/dolt-sql-server:latest`: [git for data](https://github.com/dolthub/dolt), MySQL protocol, run as it comes (no commit per write). In the conformance run only with `WEKAN_CONFORMANCE_DOLT=1`. What its version history gives WeKan's undo is in [VersionHistory](../VersionHistory/) |
| MongoDB 6, 7, 8, 9 | `mongo:6.0`, `mongo:7` ([docker-compose-mongodb-v7.yml](../../../../../docker-compose-mongodb-v7.yml)), `mongo:8.0`, `mongo:9.0` - queried directly, no FerretDB |
| FerretDB v1 + SAP HANA | only with `WEKAN_CONFORMANCE_HANA=1` (amd64, ~16 GB RAM, SAP's licence) |
| FerretDB 2 + PostgreSQL with DocumentDB | `ghcr.io/ferretdb/ferretdb:2` and `ghcr.io/ferretdb/postgres-documentdb:17`, the images of [docker-compose-ferretdb-v2-postgresql.yml](../../../../../docker-compose-ferretdb-v2-postgresql.yml), on a network of their own; in the conformance run only with `WEKAN_CONFORMANCE_FERRETDB2=1` |
@@ -59,7 +72,11 @@ the busy users.
It is **deterministic**: a fixed seed and a seeded generator, never
`Math.random()`, so every database is loaded with byte-for-byte identical
documents - which is what makes it possible to ask whether they answer the same.
`WEKAN_SPEEDTEST_SCALE` changes the size (1 = production, 0.05 for a quick try).
`WEKAN_SPEEDTEST_SCALE` changes the size (1 = production, 2 = twice that, the
default). **`WEKAN_SPEEDTEST_SCALE=0.05` is 1/20 of a production WeKan - 15,377
cards - and the whole run, every database, takes under half an hour instead of
hours** (with the images already pulled) (the SQLite indexes alone take 19 minutes at scale 2 and 7 seconds at
0.05). Results.md says which scale it was measured at.
After loading, the test creates **the indexes WeKan creates**. They are not
listed here: [indexes.cjs](indexes.cjs) reads every `ensureIndex(...)` call in
@@ -135,6 +152,12 @@ Things to know when reading the numbers:
That costs FerretDB some load and write time MongoDB does not pay here.
- Each database runs with its image's default settings - what an administrator
gets - not tuned.
- Results.md was measured at scale 0.05 (see "The data"), with FerretDB v1.105.0
plus the changes of "Improvements since the last run". A row's FerretDB commit
is in its name; the rows differ because a database is re-run alone after a
change to FerretDB, which does not touch the others. The full-scale tables
further down are from 2026-10-23 (v1.98 and v1.99) and are kept as the record
of what each change was worth.
- A row's FerretDB commit is in its name: the databases were measured as their
FerretDB fixes went in (the PostgreSQL row after its index fix, MySQL and
MariaDB after their `_id` fix).
@@ -208,28 +231,207 @@ and its board scope is a plain `boardId` list when no board is assigned-only. It
docker-compose for FerretDB 2 could not start with the current DocumentDB image
(`POSTGRES_DB` must be `postgres`).
## Improvements since the last run
[Results.md](Results.md) was measured on 2026-10-23 with FerretDB v1.99.0. Every
change below came after it (FerretDB v1.100.0 to v1.105.0, 2026-10-24 to
2026-10-31, and WeKan up to 2026-11-02), so **the tables in Results.md do not
show them yet** - run the speed test again for full-scale numbers. The figures
quoted here are the ones in FerretDB's own
[CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md), each
measured on a smaller database (61,507 cards, 92,000 boards or the 615,070 cards
of this test, as stated), so they show the size of a change, not a place in the
table.
**FerretDB on SQLite**
- **SQLite sorts in the database** (v1.100.0). A sorted query used to read,
decode and keep every matching document in Go to return a few - People's ten
newest users, the first page of a list. SQLite now orders the row ids by a key
that reproduces MongoDB's order for simple values, and documents are fetched a
batch at a time in that order: the ten newest of 30,000 users allocate 2.6 MB
instead of 264 MB. A document whose sort field holds an array, a document, NaN
or an infinity comes first, and then everything is sorted in Go as before, so
the answer is the same. The key is read once per row, in one JSON path.
- **Documents are stored as SQLite JSONB** (v1.101.0). Every filter, sort key,
index and multikey trigger used to parse the JSON text of a document again.
New collections are binary, and an existing database is converted once when
it is opened, table by table in a transaction (204,212 documents took 81 s; a
table that fails stays text and keeps working). On 61,507 cards, due cards went
from 1,003 to 519 ms, All Boards from 95 to 42 ms and creating the 44 indexes
from 107 to 70 s. After the conversion an older FerretDB cannot read the
database.
- **`distinct` works when documents lack the field** (v1.105.0), which a database
migrated from MongoDB 3 needs on its first start.
- **A membership test reads the array from the column, not from the text of `->`**
(found by profiling Due Cards, fixed after v1.105.0). `{members: {$elemMatch:
{userId: X}}}` was `json_each(col->"members")`: `->` renders the whole array as
JSON text and `json_each` parses that text again, for every row. It is now
`json_each(col, '$."members"')`, which SQLite walks in place. The visible-boards
query went from 26.2 to 15.7 ms on 2,308 boards, and in the speed test All Boards
count from 6.9 to 4.9 ms, All Boards page from 5.9 to 3.9 ms and Due Cards from
54.5 to 43.9 ms (the SQLite total 82.7 → 68.3 ms); the 169 answers of 12 queries
are identical to before.
**FerretDB on PostgreSQL**
- **Membership tests use a GIN index** (v1.100.0): an index whose leading key is
`ARRAY.FIELD` (`members.userId`) also gets a `jsonb_path_ops` GIN index of the
array, and `{members: {$elemMatch: {userId: X}}}` uses it.
- **`$or` and `$elemMatch` are conditions in the database** (v1.101.0), so Due
Cards' query went from 2,031 to 815 ms on 61,507 cards.
- **A top-level `$and` is pushed down** (v1.100.0). WeKan's Due Cards wraps its
conditions in one, and every document was read.
- **A table is analyzed once its indexes are created** (found by the small run,
fixed after v1.105.0). PostgreSQL has statistics for an expression index
(`_jsonb->'field'`) only after `ANALYZE`, which autovacuum runs a minute or
more after a bulk load. Before that the planner guessed **one row** for a Due
Cards query that returns 2,000, and chose a plan with no parallelism: 407 ms
on 15,377 cards, 84 ms after. The whole PostgreSQL row went from 454 ms to
145 ms and from last place to eighth. The same `ANALYZE TABLE` was tried on
MySQL and MariaDB and made no difference (100 and 279 ms before, 106 and 283
after), so it was not kept.
**FerretDB on MySQL and MariaDB**
- **Reads use their indexes** (v1.100.0): an equality or `$in` narrows through
the index's generated column. A card by `_id` among 615,070 went from 3.4 s
(MySQL) and 18.7 s (MariaDB) to 1.8 ms. Generated columns are `VIRTUAL`, so
adding the 44 indexes of a 600,000-document database takes 157 s, not 20
minutes; an index of four or more fields indexes prefixes, so it fits the
3,072-byte key limit that three of WeKan's compound indexes used to exceed;
and a path with a dash or a digit (`services.accounts-lockout.unlockTime`, `profile.emailBuffer.0`) no longer
fails with "Invalid JSON path expression".
- **Multikey side tables** (v1.100.0), as on SQLite, so a membership test reads
only the matching documents. Without the right to create triggers (MySQL with
binary logging and no SUPER) nothing is created and queries read as before.
- **An `$or` of `_id` sets is one `UNION ALL`** (v1.100.0): All Boards for a user
with a starred board went from 900 ms (MySQL) and 5.7 s (MariaDB) among 92,000
boards to 6-7 ms.
- **A long `$in` is one `IN`** (v1.101.0), not 2,596 `JSON_CONTAINS` in an OR
(52 million comparisons over 20,000 cards).
- **Updates and deletes by `_id` use the `_id` index** (v1.99.0, listed above).
**WeKan** - less work for the database, which matters more the bigger it is:
- **Collection scans are gone from the hourly and startup paths.** The recurring
cards job has a partial index on `recurrenceInterval` (it read 1,108,017 cards
in 1,155 ms every hour); search sessions have the indexes their upsert and
lookup use, and the stale sessions of every user are removed (one instance had
4,751 taking 10 GB); the board key backfill is one `$in` on the key index; the
list sync scan and the due-date lookup have partial and sparse indexes.
- **One server does it, not all of them.** The startup schema re-check is claimed
in `_wekan_migration`, the daily due-date reminder scan and the database
health probe are claimed per period, so 24 replicas no longer scan, send or
report 24 times.
- **Admin Panel / Problems / Database** reports what makes an instance slow: a
query that read a whole collection of at least 10,000 documents (which
collection, the command, the operators per field and the `queryHash`), and a
high read rate. The speed test's Due Cards and All Boards numbers say what a
query costs on one database; this says which query an instance is actually
asking.
## What could still be improved
- **Sorting and `limit` in the database.** FerretDB v1 sorts in Go: People's ten
newest users, All Boards' first page and a card window read and sort every
matching document. Pushing an ORDER BY down needs MongoDB's ordering across
types, missing fields and arrays to be exact in SQL.
- **Due Cards on SQLite, 44 ms of the 68 ms total.** A CPU profile of FerretDB
while it ran Due Cards (taken before the change above): half the CPU was inside
SQLite, and about a fifth of all of it converting JSON between binary and text
(`_jsonTranslateBlobToText` 10%, `_jsonTranslateTextToBlob` 10%, besides
`_jsonLookupStep`); another quarter was Go runtime (thread signalling,
`madvise`, the network poller). Of the cards query's 24 ms, 18 ms is the index
lookups (the 700-board `IN` list) and 6 ms the five per-user conditions
(`members`, `assignees`, `requesters`, `assigners`, `userId`), each of which
evaluates `->` three times per row. Reading each once, as
`json_type(col,'$."f"') = 'array' OR col->>'$."f"' = ?`, took that step from
24.4 to 20.5 ms with the same rows - **not done**, because it may only be applied
to a field no index covers (an index is matched by the expression `col->"f"`),
and FerretDB's WHERE builder does not know which fields are indexed.
- **Tried on SQLite and not worth it, or not conclusive**: `ANALYZE` made no
difference (FerretDB chooses its indexes itself); a Unix socket instead of TCP
saves about 0.05 ms (20%) on a point read (0.24 → 0.19 ms), which is free when
WeKan and FerretDB share a machine (`--listen-unix`); `GOGC=400` gave 10-15% on
some queries in one test (board cards 5.3 → 4.4 ms) and nothing in another that
also set a memory limit, so it is a lead, not a result.
- **The busiest users.** What remains of All Boards for a member of thousands of
boards, and of Due Cards, is decoding those boards and sorting them in Go.
- **SQLite stores JSON as text.** Every `->` parses the document again - which is
also why creating an index takes 19 minutes for SQLite and 115 s for PostgreSQL,
whose `jsonb` is binary. Storing SQLite's JSONB would remove that, but changes
the storage format and needs a migration.
- **Multikey indexes for PostgreSQL, MySQL and MariaDB.** Only SQLite has the side
tables; All Boards takes 8-9 s on the others.
- **MySQL and MariaDB reads do not use their indexes**: equality is
`JSON_CONTAINS(JSON_EXTRACT(doc, ?))`, so a card by `_id` takes 3.4 s and 18.7 s
(and an edit as long - it reads the card first). Their indexes are generated
`VARCHAR(255)` columns added with a table rebuild each (20 and 40 minutes for
44 indexes; VIRTUAL columns would avoid the rebuild), and three of WeKan's
compound indexes exceed the 3,072-byte key limit and are not created.
boards, and of Due Cards, is decoding those boards and sorting them in Go on
the databases that do not sort in SQL.
- **Sorting in the database on PostgreSQL, MySQL and MariaDB.** Only SQLite does
it. PostgreSQL was tried (2026-11-03): an `ORDER BY` of type class, number and
text (`COLLATE "C"`, MongoDB's byte order) with the documents that cannot be
ordered first, as on SQLite, and the tests matched the Go sort over mixed
types. It was **not faster**: the ten newest of 30,116 users took 52 ms, against
38-49 ms for fetching, decoding and sorting them in Go, because the Go path is
already fast after the decoding changes and the sort expressions cost about as
much. It would only save memory and transfer, so it was not kept. A sort that
wins needs an expression index on the sort field, which has to know the field
in advance; MySQL and MariaDB have the same cost structure and no measurement
that says otherwise.
- **MariaDB stores JSON as text** that every JSON function parses again, so it
keeps only what an index answers (`$or` and `$elemMatch` are not pushed down).
- **Existing MySQL/MariaDB tables** keep their case-insensitive index columns
until rebuilt.
- **Measuring it again at full scale.** Results.md is scale 0.05. A full-scale
run (`WEKAN_SPEEDTEST_SCALE=2`, hours) would show the busiest users, where the
differences are largest.
## Other databases tried
Asked for: any other fast database that FerretDB can use and that is open source.
FerretDB v1 has handlers for PostgreSQL, MySQL, SQLite and SAP HANA, so a
candidate has to speak one of those protocols **and** run the SQL the handler
writes: `jsonb` expression indexes, `@>` and GIN on PostgreSQL; `JSON`,
`JSON_EXTRACT`, generated columns and triggers on MySQL.
| Database | Result |
| --- | --- |
| [pgrust](https://github.com/malisper/pgrust) v0.4-beta (PostgreSQL in Rust, AGPL-3.0) | **Works**, same answers as MongoDB, and is in Results.md. It is not faster than PostgreSQL for WeKan's queries: 174 ms against 145 ms in total, and creating the 47 indexes takes 38 s against 1 s. |
| [TiDB](https://github.com/pingcap/tidb) 7.5 (Apache-2.0, MySQL protocol) | **Works**, same answers, and is in Results.md (240 ms, between pgrust and MariaDB). It needed one change in FerretDB: its metadata table's generated columns are `VIRTUAL`, because TiDB refuses `ALTER TABLE ... ADD COLUMN ... STORED`. This is one process with its own storage; a real cluster (PD and TiKV) is a different test. |
| [Dolt](https://github.com/dolthub/dolt) 2.6.2 (git for data, Apache-2.0, MySQL protocol) | **Works with the existing MySQL handler, no new handler**, same answers as MongoDB, and is in Results.md - and is **by far the slowest: 20,470 ms in total, 1,230x MongoDB**. Every query over cards takes about 2.8 s on 15,377 cards (a whole-table scan, about 187 µs a document: the generated-column indexes the handler relies on do not narrow a read there), All Boards 850 ms, and creating the indexes 106 s. Its point is version history, not speed; see [VersionHistory](../VersionHistory/). |
| [Noria](https://github.com/mit-pdos/noria) (MIT research prototype, Apache-2.0) | **Cannot run FerretDB.** It is a streaming dataflow system that precomputes the results of a fixed set of prepared queries. Its SQL has no `JSON` type (the parser's type list is `INT`, `VARCHAR`, `TEXT`, `BLOB`, `DATE`, ...), no triggers and no generated columns, and the MySQL handler needs all three: it uses `JSON` 79 times, `JSON_EXTRACT` 74 times and 7 generated columns. It also needs unpinned nightly Rust (2021) and ZooKeeper, and has not changed since October 2021. Not built or run. |
| [YugabyteDB](https://github.com/yugabyte/yugabyte-db) 2026.1.2 (core Apache-2.0, PostgreSQL 15 compatible) | **Cannot run FerretDB.** Started, and FerretDB's first `CREATE INDEX` fails: `INDEX on column of type 'JSONB' not yet supported`. Every FerretDB v1 index on PostgreSQL is an expression of type `jsonb`, and so is every query that uses one; YugabyteDB can index a `jsonb` column only with GIN. Supporting it means writing the indexes and conditions as `->>` text, which is a different handler. |
| [CockroachDB](https://github.com/cockroachdb/cockroach) | **Not tried: not open source.** Its LICENSE is the proprietary CockroachDB Software License, not an open-source one. |
Tried with the MySQL handler, to see how far it gets (both speak the MySQL protocol):
| Database | Result |
| --- | --- |
| [ClickHouse](https://github.com/ClickHouse/ClickHouse) 26.10 (Apache-2.0) | **Needs a handler of its own.** The first statement FerretDB sends through its MySQL interface fails: `sql: expected 0 arguments, got 2`. Behind that it has no `JSON_EXTRACT`, `JSON_CONTAINS`, triggers or generated columns as the handler uses them (it has `JSONExtract*`), and a row update is an asynchronous mutation, which is not what WeKan's edits and moves are. |
| [StarRocks](https://github.com/StarRocks/starrocks) 4.1 (Apache-2.0) | **Needs a handler of its own.** The first `CREATE TABLE` fails: `Data type of first column cannot be JSON`; its tables need a key column and a distribution. An analytical engine again, with no triggers. |
A handler for either is a new backend of the size of the SAP HANA one (about
2,000 lines of Go: query, insert, update, delete, indexes, statistics) plus its own
SQL for every filter FerretDB can push down; without the pushdown a query reads
the whole collection, which the speed test would measure as slow. That has not
been written. Not tried at all: DuckDB (no server; the Go driver needs cgo, which
FerretDB's builds do not use) and SingleStore (not open source, and its image
needs a licence key). A MongoDB-protocol database needs no FerretDB and joins the
MongoDB rows; FerretDB 2 with DocumentDB is already there.
## Opening a board in WeKan
The same databases, one level up: [app/](app/) opens a WeKan board of 200, 2,000 and
5,000 cards on FerretDB (SQLite) and on MongoDB 8 and profiles both the Meteor server
and FerretDB. The Meteor server's CPU is the same on both (0.1-0.5 s); on a 5,000-card
board FerretDB (SQLite) takes **400-600 ms longer** (to the first card +42-48%), a
small board only 70-100 ms, and it uses about 4 s of CPU to open the 5,000-card board,
a third of it filtering in Go what it read; the browser receives the same ~210 KB at
every size.
## Go databases linked into FerretDB
Could a database implemented in Go be linked into the FerretDB binary beside its
SQLite, and be faster? [embedded/](embedded/) asks that of the
[awesome-go list](https://github.com/avelino/awesome-go#databases-implemented-in-go):
Pebble, Badger, bbolt, BuntDB, Nutsdb and Clover against SQLite set up as FerretDB
sets it, on the speed test's cards, in minutes. **None is faster where it counts**:
a board's cards take the same time on every engine that is not slower, because
decoding the JSON dominates; the key-value stores win single reads and writes by
microseconds that the 0.2 ms floor of a FerretDB call hides, and would make the
handler do in Go what SQL does in SQLite. bbolt with its default fsync is 100-160x
slower on writes, Pebble 12x slower on fetching a board, Clover 90-190x slower.
## FerretDB 2
+76 -40
View File
@@ -1,12 +1,34 @@
# WeKan database speed test results
Run 2026-10-23 on Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, 12 threads, 30 GiB RAM, Linux 7.2.6-070206-generic, by
Run 2026-11-03 on Apple M2 Max, 12 threads, 32 GiB RAM, Darwin 27.2.0, by
[speedtest.sh](speedtest.sh). How it works, and what was improved because of it:
[the README](.).
The data is twice a production WeKan: 30,116 users, 92,306 boards, 221,038 lists, 222,546 swimlanes, 615,070 cards (production: 307,535 cards). Every database got identical
The data is 1/20 of a production WeKan: 753 users, 2,308 boards, 5,526 lists, 5,564 swimlanes, 15,377 cards (production: 307,535 cards). Every database got identical
documents and WeKan's own indexes, and was asked identical questions.
## At a glance
One number per database: the time of one call of each of the common WeKan queries below,
added together. **Times slower** is that sum divided by the fastest database's. Ordered
from fastest to slowest. The second pair of columns is the same for the slowest parameter -
the biggest board or the busiest user, who waits longest.
| # | Database | Total (ms) | Times slower | Slowest user (ms) | Times slower |
| ---: | --- | ---: | ---: | ---: | ---: |
| 1 | MongoDB 9.0.2 | 16.6 | 1.0x | 45.1 | 1.0x |
| 2 | MongoDB 8.0.32 | 17.4 | 1.0x | 48.8 | 1.1x |
| 3 | MongoDB 6.0.28 | 18.0 | 1.1x | 50.2 | 1.1x |
| 4 | MongoDB 7.0.43 | 18.1 | 1.1x | 50.1 | 1.1x |
| 5 | FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 57.9 | 3.5x | 154 | 3.4x |
| 6 | FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 68.3 | 4.1x | 219 | 4.9x |
| 7 | FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 103 | 6.2x | 415 | 9.2x |
| 8 | FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 145 | 8.7x | 483 | 11x |
| 9 | FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 174 | 10x | 594 | 13x |
| 10 | FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 240 | 14x | 877 | 19x |
| 11 | FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 290 | 17x | 1,063 | 24x |
| 12 | FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 20,470 | 1,230x | 20,904 | 464x |
## Speed
Milliseconds for one call - smaller is faster. Each query is asked with 15 parameters (the
@@ -16,66 +38,80 @@ fastest database at the top.
| Database | Total | Login | User by id | All Boards count | All Boards page | Board by id | Board lists | Board swimlanes | Board cards | Card window | Card count | Cards per list | Card by id | Card search | Due Cards | People page | Edit card | Move card | Add card |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| MongoDB 9.0.4 | **495** | 0.75 | 0.66 | 5.76 | 16.4 | 0.68 | 1.06 | 0.98 | 47.7 | 0.77 | 1.61 | 2.62 | 0.66 | 5.34 | 394 | 13.6 | 0.77 | 1.16 | 1.00 |
| MongoDB 6.0.28 | **562** | 0.78 | 0.73 | 5.49 | 21.0 | 0.83 | 1.01 | 0.93 | 48.2 | 0.89 | 2.01 | 4.38 | 0.74 | 4.76 | 447 | 20.9 | 0.83 | 0.90 | 0.77 |
| MongoDB 7.0.45 | **565** | 0.86 | 0.74 | 6.29 | 23.7 | 0.79 | 1.07 | 1.00 | 48.9 | 0.87 | 2.00 | 4.35 | 0.72 | 5.52 | 445 | 20.7 | 0.79 | 0.77 | 0.76 |
| MongoDB 8.0.34 | **577** | 0.81 | 0.69 | 6.83 | 16.8 | 0.77 | 1.19 | 1.01 | 48.5 | 0.88 | 1.90 | 2.87 | 0.69 | 6.28 | 470 | 15.6 | 0.78 | 0.89 | 0.78 |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | **18,848** | 0.99 | 0.93 | 1,405 | 1,655 | 1.08 | 4.15 | 2.94 | 223 | 67.2 | 86.0 | 119 | 0.89 | 81.7 | 12,604 | 2,582 | 4.69 | 6.36 | 2.32 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **88,884** | 1.35 | 1.03 | 183 | 241 | 1.15 | 1.72 | 1.68 | 113 | 53.5 | 43.5 | 46.0 | 0.93 | 46.1 | 88,013 | 118 | 7.89 | 6.09 | 6.20 |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | **97,536** | 1.76 | 2.05 | 11,389 | 11,396 | 1.82 | 3.41 | 2.43 | 189 | 2.43 | 171 | 173 | 1.79 | 171 | 73,217 | 791 | 9.24 | 8.49 | 6.68 |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | **98,435** | 1.24 | 1.21 | 159 | 149 | 1.36 | 2.14 | 1.62 | 217 | 1.46 | 121 | 132 | 1.14 | 137 | 96,035 | 1,444 | 9.51 | 11.9 | 9.48 |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | **106,082** | 1.86 | 3.09 | 1,073 | 1,082 | 1.86 | 2.90 | 2.37 | 249 | 4.88 | 133 | 140 | 1.74 | 153 | 101,505 | 1,687 | 14.4 | 15.7 | 11.6 |
| MongoDB 9.0.2 | **16.6** | 0.48 | 0.41 | 0.89 | 0.87 | 0.43 | 0.42 | 0.43 | 2.08 | 0.42 | 0.45 | 0.53 | 0.35 | 0.60 | 6.23 | 0.68 | 0.39 | 0.52 | 0.48 |
| MongoDB 8.0.32 | **17.4** | 0.46 | 0.41 | 0.86 | 0.85 | 0.39 | 0.48 | 0.45 | 2.14 | 0.42 | 0.49 | 0.52 | 0.35 | 0.63 | 7.09 | 0.78 | 0.38 | 0.40 | 0.34 |
| MongoDB 6.0.28 | **18.0** | 0.44 | 0.46 | 0.71 | 0.96 | 0.43 | 0.44 | 0.41 | 2.13 | 0.39 | 0.43 | 0.50 | 0.36 | 0.60 | 7.42 | 0.82 | 0.49 | 0.49 | 0.53 |
| MongoDB 7.0.43 | **18.1** | 0.45 | 0.41 | 0.71 | 0.91 | 0.41 | 0.46 | 0.41 | 2.05 | 0.40 | 0.42 | 0.51 | 0.36 | 0.55 | 7.81 | 0.76 | 0.46 | 0.52 | 0.48 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **57.9** | 0.67 | 0.55 | 2.10 | 2.82 | 0.69 | 0.71 | 0.56 | 3.21 | 1.12 | 1.06 | 1.29 | 0.52 | 1.42 | 33.8 | 1.91 | 1.87 | 1.77 | 1.85 |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | **68.3** | 0.29 | 0.23 | 4.35 | 3.40 | 0.28 | 0.42 | 0.37 | 4.91 | 0.72 | 1.69 | 2.36 | 0.22 | 1.01 | 40.7 | 3.90 | 1.36 | 1.39 | 0.67 |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | **103** | 0.92 | 0.87 | 6.13 | 6.40 | 0.91 | 0.97 | 0.88 | 7.79 | 1.04 | 5.05 | 5.13 | 0.85 | 5.75 | 35.9 | 16.9 | 2.65 | 2.87 | 2.08 |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | **145** | 0.76 | 0.67 | 5.84 | 6.14 | 0.59 | 0.57 | 0.55 | 7.70 | 0.70 | 4.54 | 4.57 | 0.50 | 5.20 | 83.8 | 16.1 | 2.34 | 2.27 | 1.66 |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | **174** | 0.75 | 0.62 | 6.00 | 6.39 | 0.70 | 0.68 | 0.61 | 7.49 | 0.73 | 4.75 | 4.70 | 0.57 | 5.29 | 112 | 15.3 | 2.59 | 2.58 | 1.95 |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | **240** | 2.00 | 1.48 | 32.7 | 33.0 | 1.44 | 1.95 | 1.68 | 10.4 | 2.96 | 7.61 | 7.59 | 1.50 | 8.35 | 97.2 | 18.4 | 4.32 | 4.81 | 2.71 |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | **290** | 1.06 | 0.90 | 11.6 | 11.6 | 1.20 | 1.13 | 0.96 | 7.68 | 1.28 | 6.63 | 6.77 | 0.91 | 6.90 | 216 | 9.12 | 2.52 | 2.48 | 1.43 |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | **20,470** | 42.6 | 1.87 | 852 | 847 | 2.09 | 188 | 84.7 | 2,876 | 2,846 | 2,857 | 2,836 | 2.06 | 2,834 | 4,126 | 56.6 | 6.94 | 6.92 | 4.75 |
### The slowest parameter
The same, for the slowest of the 15 parameters - nearly always the biggest board or the
busiest user. This is how long the person who waits longest waits. Same row order.
busiest user. This is how long the person who waits longest waits. Ordered by its own
total, so the order can differ from the table above.
| Database | Total | Login | User by id | All Boards count | All Boards page | Board by id | Board lists | Board swimlanes | Board cards | Card window | Card count | Cards per list | Card by id | Card search | Due Cards | People page | Edit card | Move card | Add card |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| MongoDB 9.0.4 | **1,110** | 0.78 | 0.69 | 28.1 | 187 | 0.72 | 1.89 | 1.63 | 297 | 0.83 | 4.48 | 9.99 | 0.68 | 28.1 | 525 | 19.9 | 0.85 | 1.48 | 1.02 |
| MongoDB 6.0.28 | **1,257** | 0.80 | 0.78 | 29.6 | 256 | 0.95 | 1.82 | 1.43 | 300 | 0.98 | 7.77 | 22.4 | 0.78 | 24.9 | 585 | 21.1 | 0.87 | 1.11 | 0.79 |
| MongoDB 7.0.45 | **1,303** | 0.88 | 0.77 | 34.1 | 291 | 0.83 | 1.83 | 1.65 | 304 | 0.89 | 7.95 | 22.1 | 0.77 | 29.6 | 583 | 20.8 | 0.84 | 1.04 | 0.79 |
| MongoDB 8.0.34 | **1,212** | 0.87 | 0.71 | 33.7 | 184 | 0.82 | 2.09 | 1.66 | 301 | 0.93 | 5.14 | 11.0 | 0.73 | 33.8 | 611 | 22.1 | 0.84 | 1.12 | 0.80 |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | **29,845** | 1.04 | 0.97 | 4,780 | 4,812 | 1.27 | 18.1 | 8.09 | 1,419 | 389 | 538 | 755 | 0.96 | 504 | 13,798 | 2,804 | 5.69 | 7.52 | 2.83 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **136,362** | 1.39 | 1.07 | 190 | 252 | 1.22 | 2.90 | 2.50 | 451 | 113 | 90.2 | 90.2 | 0.98 | 90.0 | 134,900 | 147 | 13.8 | 7.81 | 6.78 |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | **130,784** | 1.87 | 2.46 | 15,784 | 15,689 | 2.05 | 9.24 | 5.17 | 1,192 | 2.71 | 1,101 | 1,079 | 2.63 | 1,079 | 93,914 | 891 | 11.3 | 10.5 | 7.36 |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | **127,323** | 1.27 | 1.31 | 887 | 812 | 1.56 | 6.39 | 3.80 | 1,365 | 1.63 | 762 | 874 | 1.20 | 848 | 120,122 | 1,601 | 12.2 | 13.1 | 10.4 |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | **131,734** | 1.98 | 3.56 | 1,821 | 1,815 | 2.41 | 7.84 | 4.79 | 1,561 | 6.74 | 791 | 873 | 1.84 | 936 | 122,154 | 1,709 | 15.7 | 16.6 | 12.5 |
| MongoDB 9.0.2 | **45.1** | 0.59 | 0.47 | 2.54 | 4.03 | 0.48 | 0.50 | 0.50 | 13.9 | 0.45 | 0.64 | 0.90 | 0.37 | 1.87 | 15.6 | 0.71 | 0.42 | 0.59 | 0.54 |
| MongoDB 8.0.32 | **48.8** | 0.52 | 0.44 | 2.63 | 4.09 | 0.42 | 0.59 | 0.55 | 14.6 | 0.44 | 0.66 | 0.91 | 0.40 | 2.08 | 18.4 | 0.83 | 0.40 | 0.45 | 0.37 |
| MongoDB 7.0.43 | **50.1** | 0.51 | 0.46 | 2.41 | 5.25 | 0.44 | 0.54 | 0.48 | 14.3 | 0.42 | 0.64 | 1.26 | 0.40 | 1.83 | 18.7 | 0.80 | 0.52 | 0.60 | 0.54 |
| MongoDB 6.0.28 | **50.2** | 0.48 | 0.52 | 2.49 | 5.59 | 0.49 | 0.52 | 0.50 | 14.6 | 0.42 | 0.66 | 1.33 | 0.39 | 2.05 | 17.6 | 0.87 | 0.54 | 0.55 | 0.59 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **154** | 0.74 | 0.59 | 2.40 | 3.22 | 0.88 | 0.91 | 0.65 | 20.4 | 1.65 | 1.43 | 2.17 | 0.57 | 2.74 | 107 | 2.01 | 2.28 | 2.17 | 2.06 |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | **219** | 0.34 | 0.30 | 21.6 | 11.2 | 0.35 | 0.82 | 0.67 | 44.0 | 3.71 | 13.9 | 20.6 | 0.24 | 5.43 | 85.6 | 6.88 | 1.53 | 1.49 | 0.77 |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | **415** | 1.00 | 0.93 | 36.7 | 38.9 | 1.05 | 1.43 | 1.11 | 61.6 | 1.26 | 36.6 | 37.1 | 0.93 | 41.5 | 128 | 18.3 | 3.26 | 3.21 | 2.63 |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | **483** | 0.87 | 0.73 | 38.3 | 39.1 | 0.68 | 0.97 | 0.79 | 66.7 | 1.22 | 36.7 | 37.0 | 0.57 | 41.4 | 193 | 16.9 | 2.65 | 2.86 | 1.91 |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | **594** | 0.88 | 0.67 | 36.5 | 38.8 | 0.80 | 1.13 | 0.92 | 62.5 | 1.19 | 36.4 | 36.1 | 0.60 | 40.3 | 314 | 15.8 | 2.78 | 2.77 | 2.12 |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | **877** | 2.12 | 1.52 | 77.1 | 77.7 | 1.58 | 2.64 | 2.11 | 76.4 | 4.03 | 51.1 | 49.9 | 1.62 | 55.6 | 442 | 18.9 | 4.53 | 5.05 | 2.86 |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | **1,063** | 1.19 | 0.98 | 104 | 104 | 1.39 | 1.66 | 1.34 | 54.4 | 1.39 | 49.9 | 50.7 | 0.98 | 51.3 | 622 | 9.26 | 2.84 | 2.63 | 1.61 |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | **20,904** | 43.8 | 1.99 | 875 | 887 | 2.36 | 192 | 88.5 | 2,912 | 2,890 | 2,895 | 2,908 | 2.57 | 2,860 | 4,262 | 61.4 | 8.81 | 8.48 | 4.91 |
## Loading the data
Inserting the documents in batches of 1,000, then creating every index WeKan creates.
Ordered by the whole time, fastest first.
| Database | users | boards | lists | swimlanes | cards | Indexes | Index errors |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| MongoDB 9.0.4 | 0.8 s | 3.4 s | 4.5 s | 4.1 s | 17.0 s | 43.6 s | none |
| MongoDB 6.0.28 | 0.8 s | 3.3 s | 4.6 s | 4.2 s | 17.7 s | 41.6 s | none |
| MongoDB 7.0.45 | 0.8 s | 3.3 s | 4.7 s | 4.2 s | 17.3 s | 42.8 s | none |
| MongoDB 8.0.34 | 0.8 s | 3.2 s | 4.9 s | 4.3 s | 17.9 s | 45.3 s | none |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | 10.5 s | 65.5 s | 59.6 s | 41.4 s | 315.3 s | 1157.0 s | none |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 1.5 s | 6.0 s | 7.7 s | 6.9 s | 32.3 s | 219.2 s | none |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | 8.5 s | 35.9 s | 33.7 s | 28.3 s | 148.9 s | 900.9 s | none |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | 15.2 s | 83.7 s | 74.6 s | 63.3 s | 376.9 s | 116.8 s | none |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | 11.2 s | 58.6 s | 58.0 s | 53.1 s | 254.2 s | 172.0 s | none |
| MongoDB 9.0.2 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
| MongoDB 7.0.43 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
| MongoDB 6.0.28 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
| MongoDB 8.0.32 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.7 s | none |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 0.0 s | 0.1 s | 0.1 s | 0.1 s | 0.3 s | 1.3 s | none |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 0.2 s | 0.5 s | 0.4 s | 0.4 s | 2.0 s | 1.7 s | none |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 0.1 s | 0.3 s | 0.3 s | 0.2 s | 1.3 s | 7.2 s | none |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 0.1 s | 0.5 s | 0.3 s | 0.2 s | 1.9 s | 7.4 s | none |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 0.3 s | 0.6 s | 0.5 s | 0.5 s | 2.2 s | 7.9 s | none |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 0.1 s | 0.7 s | 0.5 s | 0.4 s | 3.0 s | 16.0 s | none |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 0.1 s | 0.7 s | 0.5 s | 0.4 s | 3.2 s | 38.0 s | none |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 0.2 s | 0.8 s | 0.8 s | 0.6 s | 4.2 s | 105.7 s | none |
## Same data
Every answer of every query, for every parameter, and the cards the writes left behind,
compared with **MongoDB 8.0.34**. Answers are compared as typed values with
compared with **MongoDB 8.0.32**. Answers are compared as typed values with
sorted keys; a query whose order WeKan relies on must also return the same order.
| Database | Same answers | Different |
| --- | --- | --- |
| MongoDB 9.0.4 | 19 of 19 | none |
| MongoDB 9.0.2 | 19 of 19 | none |
| MongoDB 8.0.32 | 19 of 19 | none |
| MongoDB 6.0.28 | 19 of 19 | none |
| MongoDB 7.0.45 | 19 of 19 | none |
| MongoDB 8.0.34 | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | 19 of 19 | none |
| MongoDB 7.0.43 | 19 of 19 | none |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | 19 of 19 | none |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 19 of 19 | none |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 19 of 19 | none |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 19 of 19 | none |
## The queries
@@ -103,7 +139,7 @@ Each is the query WeKan itself sends; see [queries.cjs](queries.cjs).
| Add card | cards.insertOne({ ...a new card }) | the 3 biggest boards + 12 others |
Each query is first run once per parameter to warm up (those answers are the ones compared),
then repeated for up to 5 passes or 10 seconds; 15-90 calls per query were timed.
then repeated for up to 5 passes or the time budget (WEKAN_SPEEDTEST_BUDGET_MS); 15-90 calls per query were timed.
A query too slow to be repeated in that time keeps its warm-up time.
Raw results: `.tools/log/test-db-speedtest/2026-10-23_19-33-27/speedtest-<database>.json`.
Raw results: `.tools/log/test-db-speedtest/2026-11-03_22-10-05/speedtest-<database>.json`.
@@ -0,0 +1,108 @@
# Opening a WeKan board: where the time goes
The [speed test](../) asks the database alone. This asks what a person waits for: the
whole of opening a board in WeKan, with WeKan on FerretDB (SQLite) and on MongoDB 8,
and how much of it is the database. [board-open.cjs](board-open.cjs) seeds a board of
N cards, logs in, opens it in Chromium and records
- the wall clock to the first minicard and until the DDP WebSocket has been quiet for
1.5 s (**settled**), and what crossed the WebSocket;
- the Meteor server's CPU, by who spends it (a Node inspector profile of the server);
- FerretDB's CPU (its pprof), against what it spends with nobody in the browser.
```sh
# WeKan on FerretDB: MONGO_URL=mongodb://127.0.0.1:27040/wekan?directConnection=true,
# DEFAULT_METEOR_REACTIVITY_ORDER=polling (as WeKan is deployed), meteor run --inspect=9229,
# and FerretDB with --debug-addr=127.0.0.1:8098
WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000,5000 \
--inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
```
Run on 2026-11-03/04, Apple M2 Max, **the development build of Meteor** (so the browser's
own part is larger than in production), one user, `polling` reactivity, FerretDB v1.105.0
with the SQLite membership-test change, MongoDB 8.0 in Docker. Each board is opened
twice (the second is warm), and the whole measurement was run **twice** (run 1 / run 2
below), because one run is not enough: the same board's "settled" time moved by up to
300 ms between runs.
## Result: the database is not what you wait for on a small board, and is on a big one
Milliseconds, first open of a board with this many cards (run 1 / run 2). WeKan loads
cards lazily, so the browser gets **the same ~210 KB and draws 70-120 minicards at every
size**.
| Cards | FerretDB (SQLite): first card | settled | MongoDB 8: first card | settled |
| ---: | ---: | ---: | ---: | ---: |
| 200 | 750 / 764 | 966 / 880 | 752 / 698 | 865 / 813 |
| 2,000 | 931 / 1,031 | 1,763 / 1,555 | 829 / 856 | 1,457 / 1,582 |
| 5,000 | 1,331 / 1,334 | 2,166 / 2,065 | 938 / 899 | 1,562 / 1,622 |
What is the same in both runs:
- **5,000 cards: FerretDB is 400-600 ms slower**, to the first card by 42-48% and until
settled by 27-39%.
- **2,000 cards: within the noise** for "settled" (-30 to +300 ms), and 100-175 ms
slower to the first card.
- **200 cards: 70-100 ms**, which is 9-12%.
The warm second open of run 2: 5,000 cards 1,321 against 865 ms to the first card
and 1,946 against 1,594 ms settled; 2,000 cards 904 against 824 and 1,628 against
1,446; 200 cards 721 against 689 and 934 against 805.
The Meteor server's CPU is the same on both databases within the noise (first open, run
1 / run 2: 102 / 119, 276 / 421 and 470 / 517 ms on FerretDB; 178 / 93, 245 / 342 and
521 / 483 ms on MongoDB), so the difference is **time spent waiting for the database**,
and it grows with the size of the board while the data the browser receives does not.
For a board of a few hundred cards, 0.7-0.75 s to the first card is nearly the same on
both: that is the application (the development bundle, Blaze, the DDP round trips), not
the database.
## Where the Meteor server spends its CPU (5,000 cards, first open, run 2)
| | FerretDB (517 ms) | MongoDB 8 (483 ms) |
| --- | ---: | ---: |
| Garbage collector | 31% | 24% |
| MongoDB driver (wire, decode, encode) | 19% | 22% |
| Meteor mongo (observers, polling, diff) | 12% | 13% |
| Node internals | 11% | 12% |
| WeKan code | 11% | 10% |
| Other Meteor packages and dependencies | 8% | 9% |
| Native, EJSON, DDP server (publish, mergebox) | 8% | 10% |
The DDP server itself, publish and mergebox, is 1%. The largest single functions are
the garbage collector, `CollectionDocument` (WeKan's model class), `replaceTypes`
(Meteor's BSON-to-EJSON step) and the BSON decoder.
## Where FerretDB spends its CPU
With nobody in the browser FerretDB uses 110-510 ms of CPU in the 14 s window the
profile covers. Opening a board adds, net: about nothing at 200 cards, **about 1.6 s at
2,000 and about 3.9-4.1 s at 5,000** - eight times the Meteor server's CPU for the same
open. For the 5,000-card board, of 4.41 s (run 2):
- the SQLite engine, 1.72 s (39%), a good part of it moving through the pages of the
cards table (`btreeInitPage` 18% by itself);
- 34% is `filterIterator.Next`: documents read from SQLite and filtered again in Go;
- by MongoDB command: `find` 0.79 s, `getMore` 0.74 s, `aggregate` 0.68 s (a `$group`
done in Go over the documents it read);
- the Go garbage collector, 0.57 s (13%).
Which WeKan query sends the `aggregate` during the open was **not identified**; the
debug log of a 2,000-card open contains none, so the 5,000-card profile may have
included the All Boards page's card counters (`getAllBoardsTileData`, a `$group` over
the user's boards) that the login lands on first. Treat that line as a lead.
## What this does and does not say
- It says the database layer is the part of opening a board that **scales with the board
size**, and that on FerretDB (SQLite) a 5,000-card board takes 400-600 ms longer than on
MongoDB, with the same data sent to the browser.
- It says the **Meteor server is not the bottleneck**: 0.1-0.5 s of CPU, the same on both
databases, and the DDP server is about 1% of it. A backend rewritten in another language
could save a part of that, and would not change the database or the browser.
- It does not separate the browser's own time from the wait for the server: the first
card at 0.7 s on a small board is mostly the development bundle and rendering.
- Single machine, the development build, one user, two runs, two opens per board, `settled`
meaning 1.5 s without a DDP frame. A production build and a loaded server would differ.
@@ -0,0 +1,188 @@
'use strict';
// Where the time goes when a WeKan board is opened, with WeKan running on a database
// (FerretDB on SQLite, or MongoDB): the browser's wall clock, what crosses the DDP
// WebSocket, the Meteor server's CPU by who spends it (Node inspector profile), and
// FerretDB's CPU (its pprof). The speed test asks the database alone; this asks whether
// the database is what a person waits for.
//
// # WeKan on FerretDB: meteor run --port 3310 --inspect=9229 with MONGO_URL set,
// # FerretDB with --debug-addr=127.0.0.1:8098
// WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
// node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000 \
// --inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
//
// Dev servers: the Meteor build is the development one, so the client's own time is
// larger than in production; the comparison between databases is what it is for.
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '../../../../../..');
const pw = require(path.join(ROOT, 'tests/playwright/node_modules/playwright-core'));
const { MongoClient } = require(path.join(ROOT, 'tests/playwright/node_modules/mongodb'));
const WebSocket = require(path.join(ROOT, 'node_modules/ws'));
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASE = process.env.WEKAN_BASE_URL || 'http://localhost:3310';
process.env.WEKAN_BASE_URL = BASE;
const MONGO = process.env.WEKAN_MONGO_URL || 'mongodb://127.0.0.1:27040/wekan';
process.env.WEKAN_MONGO_URL = MONGO;
const db = require(path.join(ROOT, 'tests/playwright/helpers/db.js'));
const { loginWithToken } = require(path.join(ROOT, 'tests/playwright/helpers/auth.js'));
const SIZES = arg('sizes', '200,2000').split(',').map(Number);
const INSPECT = arg('inspect', '');
const PPROF = arg('ferret-pprof', '');
const LABEL = arg('label', 'run');
const GO = arg('go', path.join(ROOT, '.tools/go/bin/go'));
const FERRET_BIN = arg('ferret-bin', path.join(ROOT, '.tools/FerretDB/bin/ferretdb'));
const QUIET_MS = 1500;
// ── seeding ─────────────────────────────────────────────────────────────────
async function seedBoard(user, size) {
const board = db.seedBoard({ ownerId: user.id, title: `Open ${size}`, listCount: 6, cardTitlesPerList: [['Template']] });
const template = db.findOne('cards', { boardId: board.boardId, title: 'Template' });
const client = await new MongoClient(MONGO).connect();
try {
const cards = client.db().collection('cards');
const docs = [];
for (let i = 0; i < size; i += 1) {
docs.push({
...template,
_id: `card${board.boardId}x${i}`,
title: `Card ${i} ${['plan', 'build', 'review', 'ship'][i % 4]} the thing`,
listId: board.listIds[i % board.listIds.length],
sort: Math.floor(i / board.listIds.length) * 100,
cardNumber: i + 2,
createdAt: new Date(), modifiedAt: new Date(), dateLastActivity: new Date(),
});
}
for (let i = 0; i < docs.length; i += 1000) await cards.insertMany(docs.slice(i, i + 1000));
} finally {
await client.close();
}
return board;
}
// ── the Meteor server's CPU, from the Node inspector ────────────────────────
async function inspector() {
const list = await (await fetch(`http://127.0.0.1:${INSPECT}/json`)).json();
const ws = new WebSocket(list[0].webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
let id = 0;
const pending = new Map();
ws.on('message', (m) => {
const msg = JSON.parse(m);
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
});
const send = (method, params = {}) => new Promise((resolve) => { id += 1; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); });
await send('Profiler.enable');
await send('Profiler.setSamplingInterval', { interval: 250 });
return {
start: () => send('Profiler.start'),
stop: async () => (await send('Profiler.stop')).result.profile,
close: () => ws.close(),
};
}
const { analyse } = require('./profileAnalysis.cjs');
// ── FerretDB's CPU, from its pprof ──────────────────────────────────────────
async function ferretProfile(seconds) {
if (!PPROF) return null;
const res = await fetch(`${PPROF}/debug/pprof/profile?seconds=${seconds}`);
const file = path.join(process.env.TMPDIR || '/tmp', `ferret-open-${Date.now()}.prof`);
require('fs').writeFileSync(file, Buffer.from(await res.arrayBuffer()));
const env = { ...process.env, HOME: path.join(ROOT, '.tools') };
const top = execFileSync(GO, ['tool', 'pprof', '-top', '-nodecount=80', FERRET_BIN, file], { encoding: 'utf8', env });
const total = /Total samples = ([\d.]+)(m?s)/.exec(top);
const cpuMs = total ? Number(total[1]) * (total[2] === 's' ? 1000 : 1) : null;
// A line reads: flat flat% sum% cum cum% name - SQLite's share is the cumulative time of its VM.
const line = top.split('\n').find((l) => /_sqlite3VdbeExec/.test(l));
const sqlite = line ? /([\d.]+)(m?s)\s+[\d.]+%\s+[\d.]+%\s+([\d.]+)(m?s)\s+[\d.]+%/.exec(line) : null;
// The cumulative top, for the first open: who in FerretDB spends it.
const cum = execFileSync(GO, ['tool', 'pprof', '-top', '-cum', '-nodecount=34', FERRET_BIN, file], { encoding: 'utf8', env })
.split('\n').filter((l) => /^\s+[\d.]+m?s/.test(l)).map((l) => l.trim().replace(/\s+/g, ' ')).slice(0, 34);
return { cum, cpuMs, sqliteMs: sqlite ? Number(sqlite[3]) * (sqlite[4] === 's' ? 1000 : 1) : null };
}
// ── one board open ──────────────────────────────────────────────────────────
async function openBoard(user, board, size) {
const browser = await pw.chromium.launch();
const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } });
const page = await context.newPage();
await page.addInitScript(() => { try { localStorage.setItem('wekan-mobile-mode', 'false'); } catch (e) { /* none */ } });
let frames = 0; let bytes = 0; let last = Date.now();
page.on('websocket', (ws) => ws.on('framereceived', (e) => { frames += 1; bytes += e.payload.length; last = Date.now(); }));
await loginWithToken(page, user.id, user.token);
await page.waitForTimeout(1500);
const insp = INSPECT ? await inspector() : null;
frames = 0; bytes = 0;
const ferret = PPROF ? ferretProfile(14) : Promise.resolve(null);
if (insp) await insp.start();
const t0 = Date.now();
await page.goto(`${BASE}/b/${board.boardId}/${board.slug}`, { waitUntil: 'commit' });
let tFirst = null;
await page.waitForSelector('.minicard', { timeout: 60000 }).then(() => { tFirst = Date.now() - t0; });
// quiet: no DDP frame for QUIET_MS
for (;;) {
if (Date.now() - last > QUIET_MS && Date.now() - t0 > (tFirst || 0)) break;
if (Date.now() - t0 > 60000) break;
await page.waitForTimeout(100);
}
const tQuiet = Date.now() - t0 - QUIET_MS;
const profile = insp ? await insp.stop() : null;
const minicards = await page.locator('.minicard').count();
const ferretResult = await ferret;
if (insp) insp.close();
await browser.close();
return { size, tFirst, tQuiet, frames, kb: Math.round(bytes / 1024), minicards, server: profile ? analyse(profile) : null, ferret: ferretResult };
}
const f = (v) => (v === null || v === undefined ? '-' : Math.round(v).toLocaleString('en-US'));
async function main() {
const user = db.seedUser();
const results = [];
// What FerretDB spends in the same 14 s with nobody in the browser: the baseline.
const idle = PPROF ? await ferretProfile(14) : null;
if (idle) console.error(`FerretDB idle for 14 s: ${f(idle.cpuMs)} ms of CPU`);
for (const size of SIZES) {
console.error(`seeding a board of ${size} cards...`);
const board = await seedBoard(user, size);
// The first open of a board is the one a person waits for; a second shows a warm server.
for (const pass of ['first open', 'second open']) {
console.error(`opening ${size} (${pass})...`);
const r = await openBoard(user, board, size);
r.pass = pass;
results.push(r);
}
}
console.log(`\n## ${LABEL}: opening a board\n`);
if (idle) console.log(`FerretDB with nobody in the browser: ${f(idle.cpuMs)} ms of CPU in the same 14 s window (subtract it).\n`);
console.log('| Cards | Open | First card (ms) | Settled (ms) | DDP frames | DDP KB | Minicards drawn | Meteor server busy (ms) | FerretDB CPU (ms) | of it SQLite (ms) |');
console.log('| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
for (const r of results) {
console.log(`| ${r.size} | ${r.pass} | ${f(r.tFirst)} | ${f(r.tQuiet)} | ${f(r.frames)} | ${f(r.kb)} | ${f(r.minicards)} | ${f(r.server && r.server.busyMs)} | ${f(r.ferret && r.ferret.cpuMs)} | ${f(r.ferret && r.ferret.sqliteMs)} |`);
}
for (const r of results.filter((x) => x.pass === 'first open' && x.server)) {
console.log(`\n### The Meteor server, ${r.size} cards, first open: ${f(r.server.busyMs)} ms of CPU\n`);
for (const [c, ms] of r.server.categories) console.log(`- ${c}: ${f(ms)} ms (${Math.round((100 * ms) / r.server.busyMs)}%)`);
if (r.ferret && r.ferret.cum) {
console.log(`\n### FerretDB, ${r.size} cards, first open: cumulative CPU by function (flat flat% sum% cum cum% name)\n`);
console.log('```');
r.ferret.cum.forEach((l) => console.log(l));
console.log('```');
}
console.log('\nTop functions:');
for (const [fn, ms] of r.server.topFunctions) console.log(`- ${f(ms)} ms ${fn}`);
}
process.exit(0);
}
if (require.main === module) main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,46 @@
'use strict';
// A Node inspector CPU profile of the Meteor server, by who spends the time. Apart from
// the browser and the database so a test can drive it with a made-up profile.
function categorise(node) {
const { functionName, url } = node.callFrame;
if (functionName === '(idle)') return 'idle';
if (functionName === '(garbage collector)') return 'garbage collector';
if (functionName === '(program)') return 'native (program)';
if (/\/mongodb\/|npm-mongo|\/bson\//.test(url)) return 'MongoDB driver (wire, decode, encode)';
if (/packages\/ddp-server|packages\/ddp-common|packages\/ddp\b/.test(url)) return 'Meteor DDP (publish, mergebox, sessions)';
if (/packages\/mongo|minimongo|diff-sequence/.test(url)) return 'Meteor mongo (observe, polling, diff)';
if (/packages\/ejson/.test(url)) return 'EJSON';
// Other Meteor packages and dependencies are not WeKan's: their paths contain /server/ too.
if (/programs\/server\/packages|node_modules/.test(url)) return 'other packages';
if (/programs\/server\/app|\/_build\/|\/models\/|\/server\/|\/imports\/|wekan/.test(url)) return 'WeKan code';
if (/^node:/.test(url) || url === '') return 'node internals';
return 'other packages';
}
function analyse(profile) {
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const cats = new Map();
const fns = new Map();
let total = 0;
profile.samples.forEach((sid, i) => {
const dt = (profile.timeDeltas[i + 1] ?? profile.timeDeltas[i] ?? 0) / 1000; // ms
const node = byId.get(sid);
const cat = categorise(node);
cats.set(cat, (cats.get(cat) || 0) + dt);
total += dt;
if (cat !== 'idle') {
const key = `${node.callFrame.functionName || '(anonymous)'} ${node.callFrame.url.split('/').slice(-2).join('/')}`;
fns.set(key, (fns.get(key) || 0) + dt);
}
});
const idle = cats.get('idle') || 0;
return {
totalMs: total, busyMs: total - idle,
categories: [...cats.entries()].filter(([c]) => c !== 'idle').sort((a, b) => b[1] - a[1]),
topFunctions: [...fns.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8),
};
}
module.exports = { categorise, analyse };
@@ -0,0 +1,148 @@
# Go databases that could be linked into FerretDB, against its SQLite
FerretDB is one Go binary, and its SQLite handler is the pure-Go driver
[modernc.org/sqlite](https://gitlab.com/cznic/sqlite) inside it. Is any database
implemented in Go (the [awesome-go list](https://github.com/avelino/awesome-go#databases-implemented-in-go))
one that could be linked in beside it, has enough to take SQLite's place, and is
faster for what WeKan asks?
**Short answer: no. Nothing is faster in a way that matters, and the ones that are
faster at a single read or write are key-value stores that would make the handler
do SQLite's job.**
This is the engine level of the question, so it can be answered in minutes and
without writing a handler for each: [bench.go](main.go) loads the speed test's cards
and asks each engine the questions WeKan asks of cards, the way a FerretDB handler
written for it would ask them.
```sh
node docs/Databases/FerretDB/1/SpeedTest/embedded/export.cjs 0.05 <dir> # the speed test's cards as JSON lines
cd docs/Databases/FerretDB/1/SpeedTest/embedded && go run . -data <dir> # go 1.24+, pure Go, no cgo
```
Run on 2026-11-03 on an Apple M2 Max, Go 1.27, one engine at a time, each in its own
directory. `-only "Pebble (CockroachDB's LSM),Nutsdb"` runs some.
## What is asked
| Column | The question |
| --- | --- |
| By id | one card by `_id` |
| Board cards | the unarchived cards of a board (an index on boardId and archived), **decoded** from JSON as FerretDB must |
| (raw, not decoded) | the same fetch without the decoding: the part that differs between engines |
| Count | how many unarchived cards a board has |
| Edit, Move | read the card, change it in Go, write it back (FerretDB's `updateOne`), 200 different cards |
| Add | insert a card |
Each read is asked of 15 boards - the 3 biggest and 12 ordinary ones, as in the
speed test - and a cell is the average of the 15 medians. Times are milliseconds
for one call, **Total** is the sum of the six and the table is ordered by it. Every
engine first has to give the right number of cards and the right counts for every
board, and the writes must be there afterwards: a fast wrong answer is no answer.
**SQLite is set up as FerretDB sets it**: WAL, `synchronous=NORMAL`, a 64 MiB cache
and 256 MiB of mmap, the document as JSON and the indexes on `json_extract`
expressions. A row with plain columns shows what SQLite could do and FerretDB does not.
The key-value stores keep a document under `d/<id>` and an index of
(boardId, archived) as keys, which is what a handler on them would have to build.
No engine fsyncs per commit except where the row says so, to match SQLite's
`NORMAL`.
## 15,377 cards (scale 0.05)
| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |
| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | Nutsdb | **1.394** | 0.0004 | 1.332 | 0.057 | 0.0066 | 0.019 | 0.032 | 0.0041 | 0.0 | 13 |
| 2 | bbolt, no fsync | **1.481** | 0.0005 | 1.349 | 0.055 | 0.0047 | 0.037 | 0.054 | 0.035 | 0.2 | 27 |
| 3 | SQLite, plain columns (not what FerretDB does) | **1.598** | 0.0067 | 1.385 | 0.116 | 0.025 | 0.057 | 0.067 | 0.058 | 0.1 | 15 |
| 4 | Badger | **1.623** | 0.0015 | 1.536 | 0.152 | 0.012 | 0.027 | 0.040 | 0.0065 | 0.0 | n/a |
| 5 | SQLite as FerretDB uses it (JSON, expression indexes) | **1.677** | 0.0078 | 1.421 | 0.113 | 0.037 | 0.073 | 0.082 | 0.057 | 0.2 | 14 |
| 6 | SQLite as FerretDB uses it, synchronous=FULL | **1.747** | 0.0079 | 1.421 | 0.118 | 0.037 | 0.098 | 0.099 | 0.084 | 0.2 | 14 |
| 7 | BuntDB (in memory with an append-only file) | **1.795** | 0.0003 | 1.470 | 0.186 | 0.166 | 0.066 | 0.072 | 0.021 | 0.3 | 12 |
| 8 | Pebble (CockroachDB's LSM) | **1.841** | 0.0028 | 1.770 | 0.298 | 0.011 | 0.021 | 0.034 | 0.0010 | 0.1 | 16 |
| 9 | bbolt, fsync on every commit (its default) | **25.5** | 0.0005 | 1.370 | 0.057 | 0.0048 | 8.062 | 8.025 | 8.015 | 0.2 | 27 |
| 10 | Clover (document database over Badger) | **145** | 0.016 | 59.1 | 58.5 | 57.2 | 9.412 | 9.635 | 9.751 | 0.7 | 41 |
## 153,768 cards (scale 0.5)
| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |
| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | SQLite, plain columns (not what FerretDB does) | **5.950** | 0.0071 | 5.693 | 0.418 | 0.062 | 0.052 | 0.072 | 0.064 | 1.1 | 147 |
| 2 | Nutsdb | **6.007** | 0.0005 | 5.923 | 0.326 | 0.024 | 0.022 | 0.033 | 0.0044 | 0.3 | 132 |
| 3 | bbolt, no fsync | **6.065** | 0.0005 | 5.909 | 0.289 | 0.019 | 0.042 | 0.056 | 0.039 | 14.5 | 274 |
| 4 | SQLite as FerretDB uses it, synchronous=FULL | **6.118** | 0.0089 | 5.726 | 0.413 | 0.103 | 0.098 | 0.097 | 0.085 | 1.8 | 141 |
| 5 | SQLite as FerretDB uses it (JSON, expression indexes) | **6.153** | 0.0082 | 5.830 | 0.430 | 0.103 | 0.072 | 0.079 | 0.062 | 1.8 | 141 |
| 6 | BuntDB (in memory with an append-only file) | **7.001** | 0.0003 | 6.130 | 0.753 | 0.682 | 0.080 | 0.083 | 0.026 | 4.3 | 124 |
| 7 | Badger | **7.419** | 0.0025 | 7.273 | 1.483 | 0.060 | 0.032 | 0.042 | 0.0096 | 0.4 | n/a |
| 8 | Pebble (CockroachDB's LSM) | **10.5** | 0.023 | 10.3 | 4.992 | 0.056 | 0.033 | 0.032 | 0.0012 | 2.0 | 67 |
| 9 | bbolt, fsync on every commit (its default) | **42.0** | 0.0006 | 5.923 | 0.296 | 0.019 | 11.8 | 12.2 | 12.1 | 14.4 | 274 |
| 10 | Clover (document database over Badger) | **1176** | 0.017 | 577 | 571 | 563 | 11.9 | 11.7 | 11.8 | 25.7 | 301 |
## What it says
- **Nothing is faster where it counts.** A board's cards cost 1.4 ms at 15,000 cards
and 5.7-6.2 ms at 154,000 for every engine that is not slower, and nearly all of
it is decoding the JSON, which is the same work whatever stores it. Totals are
within 5% of SQLite's for Nutsdb and bbolt.
- **The key-value stores win single reads and writes, by microseconds.** A card by
id takes 0.0003-0.0006 ms on bbolt, Nutsdb and BuntDB against SQLite's 0.008; an insert
0.001 ms on Pebble against 0.062; an edit 0.022 ms on Nutsdb against 0.072. But the
cheapest call through FerretDB costs about 0.2 ms in the speed test (the protocol,
the handler, the network) and most cost more, so an engine that took nothing at all would make one call
at most a few percent faster.
- **Some are slower, and by a lot.** Pebble's LSM is 12x SQLite on fetching a big
board's cards (5.0 ms against 0.43), Badger 3.5x, Clover about 1,300x (it decodes every
document to answer anything), 90-190x in total. **bbolt with its default - an fsync on every commit
- takes 8-12 ms per write, 100-160x SQLite's**; without it, it is as fast as
SQLite and not durable the same way.
- **The cost is in what is not there.** A key-value store has keys. FerretDB's SQLite
handler is about 8,500 lines because filters, sorting, `$or`, `$in`, regular
expressions, aggregation, indexes and statistics are SQL there. On a key-value
store the handler would do them in Go, and this benchmark asked only the six
easiest questions. Of the stores here only BuntDB and Clover have indexes on JSON
fields, and neither has a query language that could take SQLite's place.
- **Loading** at 154,000 cards: Badger 0.4 s, Nutsdb 0.3 s, Pebble 2.0 s, SQLite 1.8
s, BuntDB 4.3 s, bbolt 14.5 s (unsorted inserts), Clover 26 s.
- Disk: SQLite 141 MB, Pebble 67, Nutsdb 132, BuntDB 124, bbolt 274, Clover 301.
## The list, entry by entry
Tested (Go library, open source, maintained, pure Go):
| Entry | Licence, last push | Result |
| --- | --- | --- |
| [go-sqlite](https://gitlab.com/cznic/sqlite) | BSD-3, FerretDB's own | the baseline, three ways |
| [Pebble](https://github.com/cockroachdb/pebble) | BSD-3, 2026-11 | fastest insert and edit, 12x slower board fetch |
| [Badger](https://github.com/dgraph-io/badger) | Apache-2.0, 2026-10 | fast writes, 3.5x slower board fetch |
| [bbolt](https://github.com/etcd-io/bbolt) | MIT, 2026-10 | as fast as SQLite without fsync, 100x slower writes with it |
| [BuntDB](https://github.com/tidwall/buntdb) | MIT, 2026-05 | fastest by id, slowest count (index scan), in memory |
| [Nutsdb](https://github.com/nutsdb/nutsdb) | Apache-2.0, 2026-10 | on par with SQLite, faster point operations |
| [Clover](https://github.com/ostafen/clover) | MIT, 2025-09 | has indexes and queries, 90-190x slower in total |
Not tested, and why:
| Entry | Why not |
| --- | --- |
| goleveldb (2024-05), tiedot (2021), pudge (2021), scribble (2023), ledisdb (2023), hare, diskv, libradb | not maintained since 2021-2024, or toys; the engines that matter of their kind (LSM, B+tree) are Pebble, Badger and bbolt, tested |
| Bitcask, pogreb, moss, LotusDB, RoseDB, Coffer, tempdb | more key-value stores of the same kind; they would also need the handler to do what SQL does |
| gedb, [minisql](https://github.com/RichardKnop/minisql) (54 stars) | not looked at beyond their descriptions: a MongoDB-like store and a young single-file SQL database |
| [rqlite](https://github.com/rqlite/rqlite), [redka](https://github.com/nalgeon/redka) | they are SQLite underneath, so not faster by construction |
| [Dolt](https://github.com/dolthub/dolt), [TiDB](https://github.com/pingcap/tidb) | tested as servers in the [speed test](../) (Dolt the slowest of all, TiDB 2.3x MySQL's total); embedding their engines (go-mysql-server) would bring the same engines and far more code |
| CockroachDB | not open source (proprietary licence) |
| immudb | GitHub does not recognise its licence as an open-source one (`NOASSERTION`); an immutable ledger |
| DiceDB, godis, rotom, Vasto, ledisdb | Redis-style servers, not libraries |
| Dgraph, EliasDB, NornicDB, MemHop | graph databases |
| InfluxDB, Prometheus, VictoriaMetrics, LinDB, lynxdb, nanotdb, unitdb | time series and logs |
| Milvus | vector database |
| column | in-memory columnar store |
| objectbox-go | needs cgo |
| CovenantSQL, Databunker, NoKV, piladb | blockchain, a PII vault, a metadata service, a stack |
## What would be needed to know more
A real answer for one engine means a handler for it: query pushdown, updates,
indexes, statistics, 2,000-8,000 lines each. This benchmark says where it could pay:
nowhere on reads (decoding dominates), and only on small writes, which the protocol
hides. It does not measure what SQLite does in the engine and a key-value store
would do in Go - sorting, `$or`, regular expressions, aggregation - where SQLite can
only be ahead.
@@ -0,0 +1,120 @@
package main
import (
"github.com/dgraph-io/badger/v4"
)
type badgerEng struct {
db *badger.DB
dir string
}
func openBadger() maker {
return maker{name: "Badger", open: func(dir string) (Engine, error) {
db, err := badger.Open(badger.DefaultOptions(dir).WithLogger(nil).WithSyncWrites(false))
if err != nil {
return nil, err
}
return &badgerEng{db: db, dir: dir}, nil
}}
}
func (e *badgerEng) Name() string { return "Badger" }
func (e *badgerEng) Load(cards []Card) error {
wb := e.db.NewWriteBatch()
defer wb.Cancel()
for _, c := range cards {
if err := wb.Set(docKey(c.ID), c.Raw); err != nil {
return err
}
if err := wb.Set(indexKey(c), nil); err != nil {
return err
}
}
return wb.Flush()
}
func (e *badgerEng) ByID(id string) ([]byte, error) {
var out []byte
err := e.db.View(func(tx *badger.Txn) error {
it, err := tx.Get(docKey(id))
if err != nil {
return err
}
out, err = it.ValueCopy(nil)
return err
})
return out, err
}
func (e *badgerEng) BoardCards(board string) ([][]byte, error) {
var out [][]byte
err := e.db.View(func(tx *badger.Txn) error {
p := boardPrefix(board)
it := tx.NewIterator(badger.IteratorOptions{Prefix: p})
defer it.Close()
var ids []string
for it.Rewind(); it.Valid(); it.Next() {
ids = append(ids, idFromIndexKey(it.Item().Key()))
}
for _, id := range ids {
item, err := tx.Get(docKey(id))
if err != nil {
return err
}
v, err := item.ValueCopy(nil)
if err != nil {
return err
}
out = append(out, v)
}
return nil
})
return out, err
}
func (e *badgerEng) CountBoard(board string) (int, error) {
n := 0
err := e.db.View(func(tx *badger.Txn) error {
it := tx.NewIterator(badger.IteratorOptions{Prefix: boardPrefix(board)})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
n++
}
return nil
})
return n, err
}
func (e *badgerEng) Update(id string, change func([]byte) ([]byte, error)) error {
return e.db.Update(func(tx *badger.Txn) error {
item, err := tx.Get(docKey(id))
if err != nil {
return err
}
d, err := item.ValueCopy(nil)
if err != nil {
return err
}
next, err := change(d)
if err != nil {
return err
}
return tx.Set(docKey(id), next)
})
}
func (e *badgerEng) Insert(c Card) error {
return e.db.Update(func(tx *badger.Txn) error {
if err := tx.Set(docKey(c.ID), c.Raw); err != nil {
return err
}
return tx.Set(indexKey(c), nil)
})
}
// SizeBytes is -1: Badger keeps a recent write in its memtable and preallocates its
// log files, so the directory says nothing about what the data takes.
func (e *badgerEng) SizeBytes() int64 { return -1 }
func (e *badgerEng) Close() error { return e.db.Close() }
@@ -0,0 +1,120 @@
package main
import (
"bytes"
"fmt"
"path/filepath"
bolt "go.etcd.io/bbolt"
)
type boltEng struct {
db *bolt.DB
dir string
}
// bbolt fsyncs on every commit, which the others here do not; "NoSync" is the same
// engine without it, for a like-for-like with SQLite's synchronous=NORMAL.
func openBolt(name string, noSync bool) maker {
return maker{name: name, open: func(dir string) (Engine, error) {
db, err := bolt.Open(filepath.Join(dir, "cards.bolt"), 0o600, &bolt.Options{NoSync: noSync})
if err != nil {
return nil, err
}
db.NoSync = noSync
return &boltEng{db: db, dir: dir}, nil
}}
}
func (e *boltEng) Name() string { return "bbolt" }
func (e *boltEng) Load(cards []Card) error {
return e.db.Update(func(tx *bolt.Tx) error {
d, err := tx.CreateBucketIfNotExists([]byte("d"))
if err != nil {
return err
}
b, err := tx.CreateBucketIfNotExists([]byte("b"))
if err != nil {
return err
}
for _, c := range cards {
if err := d.Put([]byte(c.ID), c.Raw); err != nil {
return err
}
if err := b.Put(indexKey(c)[2:], nil); err != nil { // without the b/
return err
}
}
return nil
})
}
func (e *boltEng) ByID(id string) ([]byte, error) {
var out []byte
err := e.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket([]byte("d")).Get([]byte(id))
if v == nil {
return fmt.Errorf("not found")
}
out = append([]byte(nil), v...)
return nil
})
return out, err
}
func (e *boltEng) scan(tx *bolt.Tx, board string, f func(id string) error) error {
p := boardPrefix(board)[2:]
c := tx.Bucket([]byte("b")).Cursor()
for k, _ := c.Seek(p); k != nil && bytes.HasPrefix(k, p); k, _ = c.Next() {
if err := f(idFromIndexKey(k)); err != nil {
return err
}
}
return nil
}
func (e *boltEng) BoardCards(board string) ([][]byte, error) {
var out [][]byte
err := e.db.View(func(tx *bolt.Tx) error {
d := tx.Bucket([]byte("d"))
return e.scan(tx, board, func(id string) error {
out = append(out, append([]byte(nil), d.Get([]byte(id))...))
return nil
})
})
return out, err
}
func (e *boltEng) CountBoard(board string) (int, error) {
n := 0
err := e.db.View(func(tx *bolt.Tx) error { return e.scan(tx, board, func(string) error { n++; return nil }) })
return n, err
}
func (e *boltEng) Update(id string, change func([]byte) ([]byte, error)) error {
return e.db.Update(func(tx *bolt.Tx) error {
d := tx.Bucket([]byte("d"))
cur := d.Get([]byte(id))
if cur == nil {
return fmt.Errorf("not found")
}
next, err := change(append([]byte(nil), cur...))
if err != nil {
return err
}
return d.Put([]byte(id), next)
})
}
func (e *boltEng) Insert(c Card) error {
return e.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket([]byte("d")).Put([]byte(c.ID), c.Raw); err != nil {
return err
}
return tx.Bucket([]byte("b")).Put(indexKey(c)[2:], nil)
})
}
func (e *boltEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *boltEng) Close() error { return e.db.Close() }
@@ -0,0 +1,101 @@
package main
import (
"encoding/json"
"fmt"
"path/filepath"
"github.com/tidwall/buntdb"
)
// BuntDB keeps everything in memory and writes an append-only file. It has indexes
// on JSON fields of its own, so no index keys are built by hand.
type buntEng struct {
db *buntdb.DB
dir string
}
func openBunt() maker {
return maker{name: "BuntDB (in memory with an append-only file)", open: func(dir string) (Engine, error) {
db, err := buntdb.Open(filepath.Join(dir, "cards.db"))
if err != nil {
return nil, err
}
return &buntEng{db: db, dir: dir}, nil
}}
}
func (e *buntEng) Name() string { return "BuntDB" }
func (e *buntEng) Load(cards []Card) error {
if err := e.db.Update(func(tx *buntdb.Tx) error {
for _, c := range cards {
if _, _, err := tx.Set("card:"+c.ID, string(c.Raw), nil); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return e.db.CreateIndex("board", "card:*", buntdb.IndexJSON("boardId"), buntdb.IndexJSON("archived"))
}
func (e *buntEng) ByID(id string) ([]byte, error) {
var out []byte
err := e.db.View(func(tx *buntdb.Tx) error {
v, err := tx.Get("card:" + id)
out = []byte(v)
return err
})
return out, err
}
func (e *buntEng) pivot(board string) string {
b, _ := json.Marshal(board)
return fmt.Sprintf(`{"boardId":%s,"archived":false}`, b)
}
func (e *buntEng) BoardCards(board string) ([][]byte, error) {
var out [][]byte
err := e.db.View(func(tx *buntdb.Tx) error {
return tx.AscendEqual("board", e.pivot(board), func(_, v string) bool {
out = append(out, []byte(v))
return true
})
})
return out, err
}
func (e *buntEng) CountBoard(board string) (int, error) {
n := 0
err := e.db.View(func(tx *buntdb.Tx) error {
return tx.AscendEqual("board", e.pivot(board), func(_, _ string) bool { n++; return true })
})
return n, err
}
func (e *buntEng) Update(id string, change func([]byte) ([]byte, error)) error {
return e.db.Update(func(tx *buntdb.Tx) error {
v, err := tx.Get("card:" + id)
if err != nil {
return err
}
next, err := change([]byte(v))
if err != nil {
return err
}
_, _, err = tx.Set("card:"+id, string(next), nil)
return err
})
}
func (e *buntEng) Insert(c Card) error {
return e.db.Update(func(tx *buntdb.Tx) error {
_, _, err := tx.Set("card:"+c.ID, string(c.Raw), nil)
return err
})
}
func (e *buntEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *buntEng) Close() error { return e.db.Close() }
@@ -0,0 +1,143 @@
package main
import (
"encoding/json"
c "github.com/ostafen/clover/v2"
d "github.com/ostafen/clover/v2/document"
q "github.com/ostafen/clover/v2/query"
)
// Clover is a document database with indexes and queries of its own, over Badger.
type cloverEng struct {
db *c.DB
dir string
}
func openClover() maker {
return maker{name: "Clover (document database over Badger)", open: func(dir string) (Engine, error) {
db, err := c.Open(dir)
if err != nil {
return nil, err
}
if err := db.CreateCollection("cards"); err != nil {
return nil, err
}
return &cloverEng{db: db, dir: dir}, nil
}}
}
func (e *cloverEng) Name() string { return "Clover" }
func toDoc(raw []byte) (*d.Document, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
// _id is Clover's own (a UUID); the card's id is kept in cid.
m["cid"] = m["_id"]
delete(m, "_id")
return d.NewDocumentOf(m), nil
}
// fromDoc is the card as JSON again, with its own _id back.
func fromDoc(doc *d.Document) ([]byte, error) {
m := map[string]any{}
for k, v := range doc.AsMap() {
m[k] = v
}
m["_id"] = m["cid"]
delete(m, "cid")
delete(m, "_id_clover")
return json.Marshal(m)
}
func (e *cloverEng) Load(cards []Card) error {
docs := make([]*d.Document, 0, len(cards))
for _, card := range cards {
doc, err := toDoc(card.Raw)
if err != nil {
return err
}
docs = append(docs, doc)
}
if err := e.db.Insert("cards", docs...); err != nil {
return err
}
for _, f := range []string{"cid", "boardId"} {
if err := e.db.CreateIndex("cards", f); err != nil {
return err
}
}
return nil
}
func (e *cloverEng) one(id string) (*d.Document, error) {
return e.db.FindFirst(q.NewQuery("cards").Where(q.Field("cid").Eq(id)))
}
func (e *cloverEng) ByID(id string) ([]byte, error) {
doc, err := e.one(id)
if err != nil || doc == nil {
return nil, err
}
return fromDoc(doc)
}
func (e *cloverEng) query(board string) *q.Query {
return q.NewQuery("cards").Where(q.Field("boardId").Eq(board).And(q.Field("archived").Eq(false)))
}
func (e *cloverEng) BoardCards(board string) ([][]byte, error) {
docs, err := e.db.FindAll(e.query(board))
if err != nil {
return nil, err
}
// Clover returns decoded documents. They are encoded again so the runner decodes
// them like every other engine's - which charges Clover for a step it does not need.
out := make([][]byte, 0, len(docs))
for _, doc := range docs {
b, err := fromDoc(doc)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, nil
}
func (e *cloverEng) CountBoard(board string) (int, error) { return e.db.Count(e.query(board)) }
func (e *cloverEng) Update(id string, change func([]byte) ([]byte, error)) error {
doc, err := e.one(id)
if err != nil {
return err
}
raw, err := fromDoc(doc)
if err != nil {
return err
}
next, err := change(raw)
if err != nil {
return err
}
var m map[string]any
if err := json.Unmarshal(next, &m); err != nil {
return err
}
m["cid"] = m["_id"]
delete(m, "_id")
return e.db.Update(q.NewQuery("cards").Where(q.Field("cid").Eq(id)), m)
}
func (e *cloverEng) Insert(card Card) error {
doc, err := toDoc(card.Raw)
if err != nil {
return err
}
_, err = e.db.InsertOne("cards", doc)
return err
}
func (e *cloverEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *cloverEng) Close() error { return e.db.Close() }
@@ -0,0 +1,17 @@
package main
// engines lists what is compared, the baseline first.
func engines() []maker {
return []maker{
openSQLite("SQLite as FerretDB uses it (JSON, expression indexes)", "normal", false),
openSQLite("SQLite, plain columns (not what FerretDB does)", "normal", true),
openSQLite("SQLite as FerretDB uses it, synchronous=FULL", "full", false),
openPebble(),
openBadger(),
openBolt("bbolt, no fsync", true),
openBolt("bbolt, fsync on every commit (its default)", false),
openBunt(),
openNuts(),
openClover(),
}
}
@@ -0,0 +1,35 @@
'use strict';
// Writes the speed test's deterministic data as JSON lines for the Go benchmark of
// embedded engines (bench.go): the cards, with the fields the queries read, and the
// parameters (the 3 biggest boards and 12 ordinary ones) the same way run.cjs picks
// them. node embedded/export.cjs <scale> <out-directory>
const fs = require('fs');
const path = require('path');
const data = require('../data.cjs');
const scale = Number(process.argv[2] || '0.05');
const out = path.resolve(process.argv[3] || '.');
fs.mkdirSync(out, { recursive: true });
const plan = data.plan({ scale });
const lines = [];
const perBoard = new Map();
let n = 0;
for (const batch of data.documents(plan, 'cards', 5000)) {
for (const doc of batch) {
lines.push(JSON.stringify(doc, (k, v) => (v instanceof Date ? { $date: v.getTime() } : v)));
perBoard.set(doc.boardId, (perBoard.get(doc.boardId) || 0) + 1);
n += 1;
}
}
fs.writeFileSync(path.join(out, 'cards.jsonl'), `${lines.join('\n')}\n`);
// Parameters: the 3 biggest boards, then 12 ordinary ones spread over the rest.
const boards = [...perBoard.entries()].sort((a, b) => b[1] - a[1]);
const biggest = boards.slice(0, 3).map(b => b[0]);
const rest = boards.slice(3);
const step = Math.max(1, Math.floor(rest.length / 12));
const ordinary = Array.from({ length: 12 }, (_, i) => rest[Math.min(rest.length - 1, i * step)][0]);
fs.writeFileSync(path.join(out, 'params.json'), JSON.stringify({ scale, cards: n, boards: [...biggest, ...ordinary], sizes: boards.slice(0, 3).map(b => b[1]) }));
console.log(`${n} cards, ${perBoard.size} boards; biggest boards have ${boards.slice(0, 3).map(b => b[1]).join(', ')} cards`);
@@ -0,0 +1,86 @@
module embeddedbench
go 1.26.0
require (
github.com/cockroachdb/pebble v1.1.5
github.com/dgraph-io/badger/v4 v4.9.6
github.com/nutsdb/nutsdb v1.1.0
github.com/ostafen/clover/v2 v2.0.0-alpha.3
github.com/tidwall/buntdb v1.3.2
go.etcd.io/bbolt v1.5.0
modernc.org/sqlite v1.61.0
)
require (
github.com/DataDog/zstd v1.4.5 // indirect
github.com/antlabs/stl v0.0.2 // indirect
github.com/antlabs/timer v0.1.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cockroachdb/errors v1.11.3 // indirect
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/edsrzf/mmap-go v1.2.0 // indirect
github.com/getsentry/sentry-go v0.27.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.4 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/orderedcode v0.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.15.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/btree v1.6.0 // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 // indirect
go.opencensus.io v0.23.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/net v0.62.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
@@ -0,0 +1,379 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antlabs/stl v0.0.2 h1:sna1AXR5yIkNE9lWhCcKbheFJSVfCa3vugnGyakI79s=
github.com/antlabs/stl v0.0.2/go.mod h1:kKrO4xrn9cfS1mJVo+/BqePZjAYMXqD0amGF2Ouq7ac=
github.com/antlabs/timer v0.1.4 h1:MHdE00MDnNfhJCmqSOdLXs35uGNwfkMwfbynxrGmQ1c=
github.com/antlabs/timer v0.1.4/go.mod h1:mpw4zlD5KVjstEyUDp43DGLWsY076Mdo4bS78NTseRE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/brianvoe/gofakeit/v6 v6.17.0 h1:obbQTJeHfktJtiZzq0Q1bEpsNUs+yHrYlPVWt7BtmJ4=
github.com/brianvoe/gofakeit/v6 v6.17.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4=
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU=
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/dgraph-io/badger/v4 v4.9.6 h1:IQqMPVGLNCQr1b4Mu8lHkYm/xyqFRsyKaFEtyLi9CCQ=
github.com/dgraph-io/badger/v4 v4.9.6/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg=
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us=
github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/nutsdb/nutsdb v1.1.0 h1:fNGFzBHGqF2mB5BF8Qk8W94c3/ZzwdCdKAH7azwx70Y=
github.com/nutsdb/nutsdb v1.1.0/go.mod h1:aKCtgSprZf2Mp1dIQD00Iya3DttoTErSSOnRx5ZtpAs=
github.com/ostafen/clover/v2 v2.0.0-alpha.3 h1:fXC7tVHQkUPFlxlj/kD98h0ngrTpIeJymaxVIqDzw3Q=
github.com/ostafen/clover/v2 v2.0.0-alpha.3/go.mod h1:5YCDt+wJDUNN1uSXE5csxSQBuJrNjidkOkJTXWuNhDY=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM=
github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg=
github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 h1:w0si+uee0iAaCJO9q86T6yrhdadgcsoNuh47LrUykzg=
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235/go.mod h1:MR4+0R6A9NS5IABnIM3384FfOq8QFVnm7WDrBOhIaMU=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -0,0 +1,31 @@
package main
import "strings"
// The key layout a handler on a key-value store would have to build: the document
// under d/<id>, and the index of (boardId, archived) as keys b/<board>/<0|1>/<id>
// with no value. A scan of the prefix b/<board>/0/ is "the unarchived cards of a
// board" and counting its keys is the count.
func docKey(id string) []byte { return []byte("d/" + id) }
func boardPrefix(board string) []byte { return []byte("b/" + board + "/0/") }
func indexKey(c Card) []byte {
a := "0"
if c.Archived {
a = "1"
}
return []byte("b/" + c.Board + "/" + a + "/" + c.ID)
}
func idFromIndexKey(k []byte) string {
s := string(k)
return s[strings.LastIndexByte(s, '/')+1:]
}
// prefixEnd is the first key after every key with the prefix.
func prefixEnd(p []byte) []byte {
end := append([]byte(nil), p...)
end[len(end)-1]++
return end
}
@@ -0,0 +1,159 @@
// Embedded engines for FerretDB, compared with the SQLite it uses today.
//
// FerretDB is one Go binary, and its SQLite handler is the modernc.org/sqlite driver
// inside it. Which of the databases implemented in Go (awesome-go) could be linked in
// beside it, and is any of them faster for what WeKan asks? This is the engine
// level of that question: the same cards, the same questions, asked of each engine
// the way a handler written for it would ask. See README.md.
//
// go run . -data <dir with cards.jsonl and params.json> [-only sqlite,pebble] [-out results.json]
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Card is one WeKan card: what the questions read, and the whole document.
type Card struct {
ID string `json:"_id"`
Board string `json:"boardId"`
List string `json:"listId"`
Archived bool `json:"archived"`
Raw []byte `json:"-"`
}
// Engine is what a FerretDB handler needs from a storage engine for WeKan.
type Engine interface {
Name() string
// Load stores the cards and builds the indexes: by _id, and by (boardId, archived).
Load(cards []Card) error
ByID(id string) ([]byte, error)
// BoardCards returns the unarchived cards of a board.
BoardCards(board string) ([][]byte, error)
CountBoard(board string) (int, error)
// Update reads the card, changes it in Go and writes it back, which is what
// FerretDB's updateOne does.
Update(id string, change func([]byte) ([]byte, error)) error
Insert(c Card) error
SizeBytes() int64
Close() error
}
type params struct {
Scale float64 `json:"scale"`
Cards int `json:"cards"`
Boards []string `json:"boards"`
}
func main() {
dataDir := flag.String("data", "", "directory with cards.jsonl and params.json")
only := flag.String("only", "", "comma separated engine names to run")
outFile := flag.String("out", "", "write the results as JSON")
workDir := flag.String("work", "", "where the engines keep their files (default: a temporary directory)")
flag.Parse()
cards, p := readData(*dataDir)
byBoard := map[string][]Card{}
for _, c := range cards {
byBoard[c.Board] = append(byBoard[c.Board], c)
}
root := *workDir
if root == "" {
var err error
root, err = os.MkdirTemp("", "embeddedbench")
must(err)
}
defer os.RemoveAll(root)
var results []Result
for _, mk := range engines() {
name := mk.name
if *only != "" && !contains(strings.Split(*only, ","), name) {
continue
}
dir := filepath.Join(root, strings.NewReplacer(" ", "_", "/", "_", "(", "", ")", "").Replace(name))
must(os.MkdirAll(dir, 0o755))
fmt.Fprintf(os.Stderr, "== %s\n", name)
res, err := run(mk.open, name, dir, cards, byBoard, p)
if err != nil {
fmt.Fprintf(os.Stderr, " FAILED: %v\n", err)
results = append(results, Result{Name: name, Error: err.Error()})
continue
}
results = append(results, *res)
}
report(results, p)
if *outFile != "" {
b, _ := json.MarshalIndent(results, "", " ")
must(os.WriteFile(*outFile, b, 0o644))
}
}
func readData(dir string) ([]Card, params) {
var p params
b, err := os.ReadFile(filepath.Join(dir, "params.json"))
must(err)
must(json.Unmarshal(b, &p))
f, err := os.Open(filepath.Join(dir, "cards.jsonl"))
must(err)
defer f.Close()
var cards []Card
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<24)
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue
}
var c Card
must(json.Unmarshal(line, &c))
c.Raw = append([]byte(nil), line...)
cards = append(cards, c)
}
must(sc.Err())
return cards, p
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func median(d []time.Duration) time.Duration {
sort.Slice(d, func(i, j int) bool { return d[i] < d[j] })
return d[len(d)/2]
}
// modify is the change an updateOne makes: decode, set, encode.
func modify(raw []byte, key string, value any) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
m[key] = value
return json.Marshal(m)
}
@@ -0,0 +1,135 @@
package main
import (
"bytes"
"errors"
"fmt"
"github.com/nutsdb/nutsdb"
)
type nutsEng struct {
db *nutsdb.DB
dir string
}
func openNuts() maker {
return maker{name: "Nutsdb", open: func(dir string) (Engine, error) {
db, err := nutsdb.Open(nutsdb.DefaultOptions, nutsdb.WithDir(dir), nutsdb.WithSyncEnable(false))
if err != nil {
return nil, err
}
for _, b := range []string{"d", "b"} {
if err := db.Update(func(tx *nutsdb.Tx) error { return tx.NewBucket(nutsdb.DataStructureBTree, b) }); err != nil {
return nil, err
}
}
return &nutsEng{db: db, dir: dir}, nil
}}
}
func (e *nutsEng) Name() string { return "Nutsdb" }
func (e *nutsEng) Load(cards []Card) error {
for start := 0; start < len(cards); start += 2000 {
end := min(start+2000, len(cards))
if err := e.db.Update(func(tx *nutsdb.Tx) error {
for _, c := range cards[start:end] {
if err := tx.Put("d", []byte(c.ID), c.Raw, nutsdb.Persistent); err != nil {
return err
}
if err := tx.Put("b", indexKey(c)[2:], []byte{1}, nutsdb.Persistent); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
}
return nil
}
func (e *nutsEng) ByID(id string) ([]byte, error) {
var out []byte
err := e.db.View(func(tx *nutsdb.Tx) error {
v, err := tx.Get("d", []byte(id))
out = append([]byte(nil), v...)
return err
})
return out, err
}
func (e *nutsEng) ids(tx *nutsdb.Tx, board string) ([]string, error) {
p := boardPrefix(board)[2:]
keys, _, err := tx.PrefixScanEntries("b", p, "", 0, 1<<30, true, false)
if err != nil {
if errors.Is(err, nutsdb.ErrPrefixScan) || errors.Is(err, nutsdb.ErrNotFoundKey) {
return nil, nil // a prefix with nothing under it is an error here, not an empty list
}
return nil, err
}
var ids []string
for _, k := range keys {
if !bytes.HasPrefix(k, p) {
continue
}
ids = append(ids, idFromIndexKey(k))
}
return ids, nil
}
func (e *nutsEng) BoardCards(board string) ([][]byte, error) {
var out [][]byte
err := e.db.View(func(tx *nutsdb.Tx) error {
ids, err := e.ids(tx, board)
if err != nil {
return err
}
for _, id := range ids {
v, err := tx.Get("d", []byte(id))
if err != nil {
return err
}
out = append(out, append([]byte(nil), v...))
}
return nil
})
return out, err
}
func (e *nutsEng) CountBoard(board string) (int, error) {
n := 0
err := e.db.View(func(tx *nutsdb.Tx) error {
ids, err := e.ids(tx, board)
n = len(ids)
return err
})
return n, err
}
func (e *nutsEng) Update(id string, change func([]byte) ([]byte, error)) error {
return e.db.Update(func(tx *nutsdb.Tx) error {
v, err := tx.Get("d", []byte(id))
if err != nil {
return fmt.Errorf("get %s: %w", id, err)
}
next, err := change(append([]byte(nil), v...))
if err != nil {
return err
}
return tx.Put("d", []byte(id), next, nutsdb.Persistent)
})
}
func (e *nutsEng) Insert(c Card) error {
return e.db.Update(func(tx *nutsdb.Tx) error {
if err := tx.Put("d", []byte(c.ID), c.Raw, nutsdb.Persistent); err != nil {
return err
}
return tx.Put("b", indexKey(c)[2:], []byte{1}, nutsdb.Persistent)
})
}
func (e *nutsEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *nutsEng) Close() error { return e.db.Close() }
@@ -0,0 +1,110 @@
package main
import (
"github.com/cockroachdb/pebble"
)
type pebbleEng struct {
db *pebble.DB
dir string
}
func openPebble() maker {
return maker{name: "Pebble (CockroachDB's LSM)", open: func(dir string) (Engine, error) {
db, err := pebble.Open(dir, &pebble.Options{})
if err != nil {
return nil, err
}
return &pebbleEng{db: db, dir: dir}, nil
}}
}
func (e *pebbleEng) Name() string { return "Pebble" }
func (e *pebbleEng) Load(cards []Card) error {
b := e.db.NewBatch()
for i, c := range cards {
if err := b.Set(docKey(c.ID), c.Raw, nil); err != nil {
return err
}
if err := b.Set(indexKey(c), nil, nil); err != nil {
return err
}
if i%5000 == 4999 {
if err := b.Commit(pebble.NoSync); err != nil {
return err
}
b = e.db.NewBatch()
}
}
if err := b.Commit(pebble.NoSync); err != nil {
return err
}
return e.db.Flush()
}
func (e *pebbleEng) ByID(id string) ([]byte, error) {
v, closer, err := e.db.Get(docKey(id))
if err != nil {
return nil, err
}
defer closer.Close()
return append([]byte(nil), v...), nil
}
func (e *pebbleEng) ids(board string) ([]string, error) {
p := boardPrefix(board)
it, err := e.db.NewIter(&pebble.IterOptions{LowerBound: p, UpperBound: prefixEnd(p)})
if err != nil {
return nil, err
}
defer it.Close()
var ids []string
for ok := it.First(); ok; ok = it.Next() {
ids = append(ids, idFromIndexKey(it.Key()))
}
return ids, it.Error()
}
func (e *pebbleEng) BoardCards(board string) ([][]byte, error) {
ids, err := e.ids(board)
if err != nil {
return nil, err
}
out := make([][]byte, 0, len(ids))
for _, id := range ids {
d, err := e.ByID(id)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, nil
}
func (e *pebbleEng) CountBoard(board string) (int, error) {
ids, err := e.ids(board)
return len(ids), err
}
func (e *pebbleEng) Update(id string, change func([]byte) ([]byte, error)) error {
d, err := e.ByID(id)
if err != nil {
return err
}
next, err := change(d)
if err != nil {
return err
}
return e.db.Set(docKey(id), next, pebble.NoSync)
}
func (e *pebbleEng) Insert(c Card) error {
b := e.db.NewBatch()
_ = b.Set(docKey(c.ID), c.Raw, nil)
_ = b.Set(indexKey(c), nil, nil)
return b.Commit(pebble.NoSync)
}
func (e *pebbleEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *pebbleEng) Close() error { return e.db.Close() }
@@ -0,0 +1,238 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
type maker struct {
name string
open func(dir string) (Engine, error)
}
// Result is one engine's numbers. Times are milliseconds for one call.
type Result struct {
Name string `json:"name"`
Error string `json:"error,omitempty"`
LoadMs float64 `json:"loadMs"`
SizeMB float64 `json:"sizeMB"`
Queries map[string]float64 `json:"queries"`
Slowest map[string]float64 `json:"slowest"`
TotalMs float64 `json:"totalMs"`
TotalSlow float64 `json:"totalSlowestMs"`
}
var queryOrder = []string{"byId", "boardCards", "countBoard", "update", "move", "insert"}
// sample times f: it is repeated until about 15 ms have passed (at most 400 times)
// and the mean of one call is returned, so a microsecond call is not lost in the
// clock. It is taken three times and the median returned.
func sample(f func(i int) error) (time.Duration, error) {
var out []time.Duration
for pass := 0; pass < 3; pass++ {
start := time.Now()
n := 0
for n < 400 && (n == 0 || time.Since(start) < 15*time.Millisecond) {
if err := f(n); err != nil {
return 0, err
}
n++
}
out = append(out, time.Since(start)/time.Duration(n))
}
return median(out), nil
}
func run(open func(string) (Engine, error), name, dir string, cards []Card, byBoard map[string][]Card, p params) (*Result, error) {
e, err := open(dir)
if err != nil {
return nil, err
}
defer e.Close()
res := &Result{Name: name, Queries: map[string]float64{}, Slowest: map[string]float64{}}
t0 := time.Now()
if err = e.Load(cards); err != nil {
return nil, fmt.Errorf("load: %w", err)
}
res.LoadMs = float64(time.Since(t0).Microseconds()) / 1000
res.SizeMB = float64(e.SizeBytes()) / (1 << 20)
// Correctness first: a fast wrong answer is no answer.
for _, b := range p.Boards {
want := 0
for _, c := range byBoard[b] {
if !c.Archived {
want++
}
}
docs, err := e.BoardCards(b)
if err != nil {
return nil, err
}
n, err := e.CountBoard(b)
if err != nil {
return nil, err
}
if len(docs) != want || n != want {
return nil, fmt.Errorf("board %s: %d cards and a count of %d, want %d", b, len(docs), n, want)
}
first := byBoard[b][0]
raw, err := e.ByID(first.ID)
if err != nil {
return nil, err
}
var back Card
if err := json.Unmarshal(raw, &back); err != nil || back.ID != first.ID || back.Board != first.Board {
return nil, fmt.Errorf("byId %s returned %q", first.ID, string(raw))
}
}
// Reads: each is asked with every parameter board, and the cell is the average of
// the per-parameter medians, like the speed test's. "Slowest" is the worst board.
reads := map[string]func(b string, i int) error{
"byId": func(b string, i int) error {
cs := byBoard[b]
_, err := e.ByID(cs[i%len(cs)].ID)
return err
},
"boardCards": func(b string, i int) error {
docs, err := e.BoardCards(b)
if err != nil {
return err
}
for _, d := range docs { // decoded, as FerretDB must
var m map[string]any
if err := json.Unmarshal(d, &m); err != nil {
return err
}
}
return nil
},
"countBoard": func(b string, i int) error { _, err := e.CountBoard(b); return err },
// The same fetch without decoding: the part that differs between engines.
"boardRaw": func(b string, i int) error { _, err := e.BoardCards(b); return err },
}
for _, q := range []string{"byId", "boardCards", "boardRaw", "countBoard"} {
var sum, worst float64
for _, b := range p.Boards {
f := reads[q]
d, err := sample(func(i int) error { return f(b, i) })
if err != nil {
return nil, fmt.Errorf("%s: %w", q, err)
}
ms := float64(d.Nanoseconds()) / 1e6
sum += ms
if ms > worst {
worst = ms
}
}
res.Queries[q] = sum / float64(len(p.Boards))
res.Slowest[q] = worst
}
// Writes: 200 different cards each, so nothing is a no-op; the mean of a call.
ids := make([]string, 0, 200)
for i := 0; i < len(cards) && len(ids) < 200; i += len(cards) / 200 {
ids = append(ids, cards[i].ID)
}
writes := []struct {
name string
f func(i int) error
}{
{"update", func(i int) error {
return e.Update(ids[i%len(ids)], func(raw []byte) ([]byte, error) { return modify(raw, "title", fmt.Sprintf("Edited %d", i)) })
}},
{"move", func(i int) error {
return e.Update(ids[(i+1)%len(ids)], func(raw []byte) ([]byte, error) {
m, err := modify(raw, "sort", 100000+i)
if err != nil {
return nil, err
}
return modify(m, "listId", "moved")
})
}},
{"insert", func(i int) error {
doc := fmt.Sprintf(`{"_id":"new%d","boardId":"%s","listId":"l","archived":false,"title":"New","sort":%d}`, i, p.Boards[0], i)
return e.Insert(Card{ID: fmt.Sprintf("new%d", i), Board: p.Boards[0], Raw: []byte(doc)})
}},
}
for _, w := range writes {
start := time.Now()
for i := 0; i < len(ids); i++ {
if err := w.f(i); err != nil {
return nil, fmt.Errorf("%s: %w", w.name, err)
}
}
ms := float64(time.Since(start).Nanoseconds()) / 1e6 / float64(len(ids))
res.Queries[w.name] = ms
res.Slowest[w.name] = ms
}
// The writes must be there.
raw, err := e.ByID(ids[0])
if err != nil {
return nil, err
}
if !strings.Contains(string(raw), `"moved"`) {
return nil, fmt.Errorf("the move was not stored: %s", raw)
}
for _, q := range queryOrder {
res.TotalMs += res.Queries[q]
res.TotalSlow += res.Slowest[q]
}
return res, nil
}
func report(results []Result, p params) {
ok := results[:0:0]
for _, r := range results {
if r.Error == "" {
ok = append(ok, r)
}
}
// Fastest first.
for i := range ok {
for j := i + 1; j < len(ok); j++ {
if ok[j].TotalMs < ok[i].TotalMs {
ok[i], ok[j] = ok[j], ok[i]
}
}
}
fmt.Printf("\nScale %v: %d cards, %d parameter boards. Milliseconds for one call; total = sum of the six.\n\n", p.Scale, p.Cards, len(p.Boards))
fmt.Println("| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |")
fmt.Println("| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |")
for i, r := range ok {
size := fmt.Sprintf("%.0f", r.SizeMB)
if r.SizeMB < 0 {
size = "n/a"
}
fmt.Printf("| %d | %s | **%s** | %s | %s | %s | %s | %s | %s | %s | %.1f | %s |\n", i+1, r.Name, ms(r.TotalMs),
ms(r.Queries["byId"]), ms(r.Queries["boardCards"]), ms(r.Queries["boardRaw"]), ms(r.Queries["countBoard"]),
ms(r.Queries["update"]), ms(r.Queries["move"]), ms(r.Queries["insert"]), r.LoadMs/1000, size)
}
for _, r := range results {
if r.Error != "" {
fmt.Printf("\n%s: %s\n", r.Name, r.Error)
}
}
_ = os.Stdout.Sync()
}
func ms(v float64) string {
switch {
case v < 0.01:
return fmt.Sprintf("%.4f", v)
case v < 10:
return fmt.Sprintf("%.3f", v)
case v < 100:
return fmt.Sprintf("%.1f", v)
default:
return fmt.Sprintf("%.0f", v)
}
}
@@ -0,0 +1,185 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"syscall"
_ "modernc.org/sqlite"
)
// SQLite as FerretDB uses it: modernc.org/sqlite, WAL, synchronous NORMAL, a 64 MiB
// cache and 256 MiB of mmap (FerretDB's defaults, metadata/pool/uri.go). The
// document is JSON text and the indexes are on json_extract expressions, which is
// how FerretDB indexes a field. "columns" keeps the same document but indexes real
// columns, which FerretDB does not do; it shows what SQLite itself could do.
type sqliteEng struct {
name string
db *sql.DB
dir string
columns bool
}
func openSQLite(name, sync string, columns bool) maker {
return maker{name: name, open: func(dir string) (Engine, error) {
path := filepath.Join(dir, "cards.sqlite")
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(wal)&_pragma=synchronous(%s)&_pragma=cache_size(-65536)&_pragma=mmap_size(268435456)&_pragma=temp_store(memory)&_pragma=busy_timeout(30000)&_txlock=immediate", path, sync)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
return &sqliteEng{name: name, db: db, dir: dir, columns: columns}, nil
}}
}
func (e *sqliteEng) Name() string { return e.name }
func (e *sqliteEng) Load(cards []Card) error {
create := `CREATE TABLE cards (doc TEXT NOT NULL)`
insert := `INSERT INTO cards (doc) VALUES (?)`
if e.columns {
create = `CREATE TABLE cards (id TEXT PRIMARY KEY, board TEXT, archived INTEGER, doc TEXT NOT NULL)`
insert = `INSERT INTO cards (id, board, archived, doc) VALUES (?, ?, ?, ?)`
}
if _, err := e.db.Exec(create); err != nil {
return err
}
tx, err := e.db.Begin()
if err != nil {
return err
}
st, err := tx.Prepare(insert)
if err != nil {
return err
}
for _, c := range cards {
if e.columns {
_, err = st.Exec(c.ID, c.Board, boolInt(c.Archived), string(c.Raw))
} else {
_, err = st.Exec(string(c.Raw))
}
if err != nil {
return err
}
}
if err = tx.Commit(); err != nil {
return err
}
idx := []string{
`CREATE UNIQUE INDEX cards_id ON cards (json_extract(doc, '$._id'))`,
`CREATE INDEX cards_board ON cards (json_extract(doc, '$.boardId'), json_extract(doc, '$.archived'))`,
}
if e.columns {
idx = []string{`CREATE INDEX cards_board ON cards (board, archived)`}
}
for _, q := range idx {
if _, err = e.db.Exec(q); err != nil {
return err
}
}
_, err = e.db.Exec(`ANALYZE`)
return err
}
func (e *sqliteEng) where() (id, board string) {
if e.columns {
return `id = ?`, `board = ? AND archived = 0`
}
return `json_extract(doc, '$._id') = ?`, `json_extract(doc, '$.boardId') = ? AND json_extract(doc, '$.archived') = 0`
}
func (e *sqliteEng) ByID(id string) ([]byte, error) {
w, _ := e.where()
var doc string
err := e.db.QueryRow(`SELECT doc FROM cards WHERE `+w, id).Scan(&doc)
return []byte(doc), err
}
func (e *sqliteEng) BoardCards(board string) ([][]byte, error) {
_, w := e.where()
rows, err := e.db.Query(`SELECT doc FROM cards WHERE `+w, board)
if err != nil {
return nil, err
}
defer rows.Close()
var out [][]byte
for rows.Next() {
var doc string
if err := rows.Scan(&doc); err != nil {
return nil, err
}
out = append(out, []byte(doc))
}
return out, rows.Err()
}
func (e *sqliteEng) CountBoard(board string) (int, error) {
_, w := e.where()
var n int
err := e.db.QueryRow(`SELECT COUNT(*) FROM cards WHERE `+w, board).Scan(&n)
return n, err
}
func (e *sqliteEng) Update(id string, change func([]byte) ([]byte, error)) error {
w, _ := e.where()
tx, err := e.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var doc string
if err = tx.QueryRow(`SELECT doc FROM cards WHERE `+w, id).Scan(&doc); err != nil {
return err
}
next, err := change([]byte(doc))
if err != nil {
return err
}
if _, err = tx.Exec(`UPDATE cards SET doc = ? WHERE `+w, string(next), id); err != nil {
return err
}
return tx.Commit()
}
func (e *sqliteEng) Insert(c Card) error {
if e.columns {
_, err := e.db.Exec(`INSERT INTO cards (id, board, archived, doc) VALUES (?, ?, ?, ?)`, c.ID, c.Board, boolInt(c.Archived), string(c.Raw))
return err
}
_, err := e.db.Exec(`INSERT INTO cards (doc) VALUES (?)`, string(c.Raw))
return err
}
func (e *sqliteEng) SizeBytes() int64 {
_, _ = e.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
return dirSize(e.dir)
}
func (e *sqliteEng) Close() error { return e.db.Close() }
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// dirSize is what the files really occupy: the blocks, not the apparent size, which
// counts the sparse files Badger preallocates.
func dirSize(dir string) int64 {
var n int64
_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
n += st.Blocks * 512
} else {
n += info.Size()
}
}
return nil
})
return n
}
+59 -12
View File
@@ -99,11 +99,42 @@ function compare(runs, reference) {
function machine() {
const cpus = os.cpus();
return `${cpus[0] ? cpus[0].model.trim() : os.arch()}, ${cpus.length} threads, ` +
`${Math.round(os.totalmem() / 2 ** 30)} GiB RAM, Linux ${os.release()}`;
`${Math.round(os.totalmem() / 2 ** 30)} GiB RAM, ${os.type()} ${os.release()}`;
}
// What the scale means, in words: 2 is twice a production WeKan, 0.05 a twentieth.
function sizeWords(scale) {
if (scale === 1) return 'as big as a production WeKan';
if (scale === 2) return 'twice a production WeKan';
if (scale > 1) return `${scale} times a production WeKan`;
const n = Math.round(1 / scale);
return Math.abs(1 / scale - n) < 0.01 ? `1/${n} of a production WeKan` : `${Math.round(scale * 100)}% of a production WeKan`;
}
// How many times slower than the fastest, as a person reads it: "1.0x", "4.9x",
// "27x", "1,300x".
function times(v, fastest) {
if (!Number.isFinite(v) || !fastest) return '-';
const r = v / fastest;
if (r < 10) return `${r.toFixed(1)}x`;
return `${Math.round(r).toLocaleString('en-US')}x`;
}
function render(runs, sameness, reference, dir) {
const sorted = [...runs].sort((a, b) => total(a) - total(b) || a.label.localeCompare(b.label));
// Fastest first, by the sum of a row. Every table below is ordered by ITS OWN
// numbers, so each reads from fastest to slowest.
const byTotal = (totalOf) => [...runs].sort((a, b) => totalOf(a) - totalOf(b) || a.label.localeCompare(b.label));
const slowestTotal = (run) => QUERIES.reduce((sum, q) => {
const r = run.queries[q.id];
return r && !r.error && r.slowest !== undefined ? sum + r.slowest : Infinity;
}, 0);
const loadTotal = (run) => {
const l = run.load || {};
const parts = ['users', 'boards', 'lists', 'swimlanes', 'cards'].map(c => l[c] && l[c].ms);
return parts.some(v => v === undefined) || run.indexTotalMs === undefined
? Infinity : parts.reduce((a, b) => a + b, 0) + run.indexTotalMs;
};
const sorted = byTotal(total);
const first = runs.map(r => r.started).sort()[0] || '';
const sizes = (runs[0] && runs[0].sizes) || {};
const lines = [];
@@ -111,16 +142,17 @@ function render(runs, sameness, reference, dir) {
lines.push(`Run ${first.slice(0, 10)} on ${machine()}, by`);
lines.push('[speedtest.sh](speedtest.sh). How it works, and what was improved because of it:');
lines.push('[the README](.).', '');
lines.push(`The data is twice a production WeKan: ${['users', 'boards', 'lists', 'swimlanes', 'cards']
lines.push(`The data is ${sizeWords(runs[0] && runs[0].scale)}: ${['users', 'boards', 'lists', 'swimlanes', 'cards']
.map(c => `${(sizes[c] || 0).toLocaleString('en-US')} ${c}`).join(', ')}` +
` (production: ${PRODUCTION.cards.toLocaleString('en-US')} cards). Every database got identical`);
lines.push('documents and WeKan\'s own indexes, and was asked identical questions.', '');
const table = (field, totalOf) => {
const order = byTotal(totalOf);
const head = ['Database', 'Total', ...QUERIES.map(q => q.title)];
lines.push(`| ${head.join(' | ')} |`);
lines.push(`| ${head.map((_, i) => (i === 0 ? '---' : '---:')).join(' | ')} |`);
for (const run of sorted) {
for (const run of order) {
const t = totalOf(run);
const cells = QUERIES.map((q) => {
const r = run.queries[q.id];
@@ -132,6 +164,22 @@ function render(runs, sameness, reference, dir) {
}
lines.push('');
};
lines.push('## At a glance', '');
lines.push('One number per database: the time of one call of each of the common WeKan queries below,');
lines.push('added together. **Times slower** is that sum divided by the fastest database\'s. Ordered');
lines.push('from fastest to slowest. The second pair of columns is the same for the slowest parameter -');
lines.push('the biggest board or the busiest user, who waits longest.', '');
lines.push('| # | Database | Total (ms) | Times slower | Slowest user (ms) | Times slower |');
lines.push('| ---: | --- | ---: | ---: | ---: | ---: |');
const fastestAvg = Math.min(...runs.map(total));
const fastestSlow = Math.min(...runs.map(slowestTotal));
sorted.forEach((run, i) => {
const t = total(run);
const w = slowestTotal(run);
lines.push(`| ${i + 1} | ${mdCell(run.engine)} | ${Number.isFinite(t) ? ms(t) : 'incomplete'} | ${times(t, fastestAvg)} | ` +
`${Number.isFinite(w) ? ms(w) : 'incomplete'} | ${times(w, fastestSlow)} |`);
});
lines.push('');
lines.push('## Speed', '');
lines.push('Milliseconds for one call - smaller is faster. Each query is asked with 15 parameters (the');
lines.push('3 biggest boards or busiest users and 12 ordinary ones, see below); a cell is the average of');
@@ -140,17 +188,16 @@ function render(runs, sameness, reference, dir) {
table('average', total);
lines.push('### The slowest parameter', '');
lines.push('The same, for the slowest of the 15 parameters - nearly always the biggest board or the');
lines.push('busiest user. This is how long the person who waits longest waits. Same row order.', '');
table('slowest', (run) => QUERIES.reduce((sum, q) => {
const r = run.queries[q.id];
return r && !r.error && r.slowest !== undefined ? sum + r.slowest : Infinity;
}, 0));
lines.push('busiest user. This is how long the person who waits longest waits. Ordered by its own');
lines.push('total, so the order can differ from the table above.', '');
table('slowest', slowestTotal);
lines.push('## Loading the data', '');
lines.push('Inserting the documents in batches of 1,000, then creating every index WeKan creates.', '');
lines.push('Inserting the documents in batches of 1,000, then creating every index WeKan creates.');
lines.push('Ordered by the whole time, fastest first.', '');
lines.push('| Database | users | boards | lists | swimlanes | cards | Indexes | Index errors |');
lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |');
for (const run of sorted) {
for (const run of byTotal(loadTotal)) {
const l = run.load || {};
const ixErrors = Object.entries(run.indexes || {}).filter(([, v]) => v.error).map(([k]) => mdCell(k));
lines.push(`| ${mdCell(run.engine)} | ${['users', 'boards', 'lists', 'swimlanes', 'cards'].map(c => seconds(l[c] && l[c].ms)).join(' | ')} | ` +
@@ -182,7 +229,7 @@ function render(runs, sameness, reference, dir) {
lines.push('');
const calls = runs.length ? Object.values(runs[0].queries).map(r => r.calls).filter(Boolean) : [];
lines.push(`Each query is first run once per parameter to warm up (those answers are the ones compared),`);
lines.push(`then repeated for up to 5 passes or 10 seconds; ${calls.length ? `${Math.min(...calls)}-${Math.max(...calls)}` : 'the'} calls per query were timed.`);
lines.push(`then repeated for up to 5 passes or the time budget (WEKAN_SPEEDTEST_BUDGET_MS); ${calls.length ? `${Math.min(...calls)}-${Math.max(...calls)}` : 'the'} calls per query were timed.`);
lines.push('A query too slow to be repeated in that time keeps its warm-up time.');
lines.push(`Raw results: \`${path.relative(path.resolve(__dirname, '../../../../..'), dir) || dir}/speedtest-<database>.json\`.`, '');
return `${lines.join('\n')}\n`;
@@ -0,0 +1 @@
{"uuid":"fb14521d-8728-4fdb-befd-132ef195c35c","telemetry":false}
@@ -0,0 +1,62 @@
# Version history in the database: Dolt and TerminusDB, and WeKan's undo and redo
WeKan's undo and redo (Ctrl+Z, Ctrl+Y, History → Restore, see
[History](../../../../Features/General/History/History.md) and
[Undo](../../../../Features/General/Undo/Undo.md)) is application code: one
`changeHistory` row per change, with who, when and which field, and a restore that
reapplies the old values oldest to newest. Some databases version their data
themselves. This asks what that would give WeKan, by trying WeKan-shaped changes
(edit a card's title, move it, archive it, delete another one) on two of them.
```sh
# Dolt, under FerretDB's MySQL handler - WeKan's writes arrive over the MongoDB protocol
docker run -d --name dolttry -p 3307:3306 -e DOLT_ROOT_PASSWORD=rootpw -e DOLT_ROOT_HOST=% \
-e DOLT_DATABASE=ferretdb dolthub/dolt-sql-server:latest
mysql ... -e 'SET GLOBAL dolt_transaction_commit = 1' # a Dolt commit per transaction
ferretdb --handler=mysql --mysql-url='mysql://root:rootpw@127.0.0.1:3307/ferretdb' --listen-addr=127.0.0.1:27022
node docs/Databases/FerretDB/1/VersionHistory/dolt-history.cjs
# TerminusDB - REST only
docker run -d --name tdbtry -p 6363:6363 terminusdb/terminusdb-server:latest
node docs/Databases/FerretDB/1/VersionHistory/terminusdb-history.cjs
```
Run on 2026-11-03: Dolt (sql-server image, MySQL 8.0.31 protocol) with FerretDB
v1.105.0, and TerminusDB 12.0.9. Both are open source (Apache-2.0).
## What was found
| WeKan needs | Dolt under FerretDB | TerminusDB |
| --- | --- | --- |
| A record of every change | **Yes, with no change to WeKan.** With `dolt_transaction_commit` on, every FerretDB write is one Dolt commit (four writes, four commits). | Yes, one commit per write. |
| Who made it | **No.** The committer is the SQL user (`root`) for every WeKan user, and the message is always `Transaction commit`. FerretDB would have to set them. | **Yes.** Each commit has the author and message the caller gives: `alice \| edit title`. |
| What changed | Whole documents: an edit is a removed row and an added row in `dolt_diff_<table>`, because FerretDB's tables have no primary key and a card is one JSON cell. Fields are found by comparing the two. | **Field by field**: `{"title": {"@before": "Write docs v2", "@after": "Write docs", "@op": "SwapValue"}}`. |
| Undo the last change | **Yes.** `CALL DOLT_REVERT('HEAD')` brought a deleted card back, read through FerretDB over the MongoDB protocol; reverting that revert is the redo. Both are commits of their own, so history is never rewritten. | **Yes**: diff the last two commits and apply it backwards (`/api/diff`, `/api/patch`). |
| Restore one older change, keep the later ones | **Another document: yes. The same document: no.** Reverting an old title edit of card c1 after c1 was moved and archived failed with `the table(s) ... are in conflict` (the whole document is one cell). The failed revert left nothing behind, and FerretDB writes kept working. | **Yes, by field.** The title was restored while the move and the archive stayed: `title: "Write docs", listId: "l2", archived: true`. A patch whose before-value no longer matches is the conflict. |
| The board as it was | **Yes**, in SQL: `SELECT ... AS OF '<commit>'`. FerretDB does not offer it over the MongoDB protocol. | **Yes**: `GET /api/document/.../local/commit/<id>`. |
| Undo "my last change" or "this board's last change" | **No.** `HEAD` is the last commit of anyone on anything. A scope means choosing commits by their diff. | The log is per database; a scope is also chosen by the diff. |
| Cost per write | **+0.4 ms**: `updateOne` took 2.34 ms with commits off and 2.77 ms with them on (200 calls each), +18%. 223 commits for 200 updates. | About 25 ms per `PUT` of a card, an HTTP call and a commit each. |
| Can WeKan run on it | **Yes**: it is a MySQL-protocol database that FerretDB's MySQL handler already runs (it is in the [speed test](../SpeedTest/)). | **No.** It has neither the MongoDB protocol nor SQL; WeKan would need a new data layer. |
## What it means for WeKan
- **WeKan's own History is still the right undo.** It knows the user, the field,
and the card, list or swimlane a change belongs to, and it restores one field
while later changes stay. Dolt cannot do that for a document edited again
afterwards, and it does not know who made a change; TerminusDB can, but WeKan
cannot run on it.
- **Dolt would add something WeKan does not have: the whole database at any
commit.** Restoring a board, or all of them, as they were an hour ago is
`AS OF` or `DOLT_RESET`, with no code in WeKan. It is a backup and audit layer
under the undo, not a replacement for it. Its costs are real: a commit per
write of everything WeKan writes (sessions, activities and search sessions
included, which are not the user's changes), so the history is mostly noise.
And Dolt is slow for WeKan's reads: in the [speed test](../SpeedTest/Results.md)
every query over cards takes about 2.8 s on 15,377 cards (MySQL: 1-8 ms), 20,470
ms in total against MySQL's 103, because it scans the table instead of using the
indexes. A production-size WeKan on it would not be usable as it is.
- **What FerretDB would need for Dolt to answer "who"**: set the commit author and
message from the MongoDB command's `comment`, or the connection's user. It does
neither today.
- **Not tried**: Dolt branches and merges for a draft board, and TerminusDB's
history of a single document, which would answer "this card's history" directly.
@@ -0,0 +1,134 @@
'use strict';
// What a database with version history gives WeKan's undo and redo: Dolt (git for
// data, MySQL protocol) under FerretDB's MySQL handler. WeKan writes through the
// MongoDB protocol as always; every FerretDB write is a Dolt transaction, and with
// dolt_transaction_commit on every transaction is a Dolt commit.
//
// node docs/Databases/FerretDB/1/VersionHistory/dolt-history.cjs \
// --uri 'mongodb://127.0.0.1:27022/?directConnection=true' --sql-port 3307 --sql-password rootpw
//
// It prints what it finds; README.md next to it explains the findings. The SQL side
// is asked through the mysql client of the mysql:9 image, so nothing is installed.
const { execFileSync } = require('child_process');
const { MongoClient } = require('mongodb');
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const URI = arg('uri', 'mongodb://127.0.0.1:27022/?directConnection=true');
const PORT = arg('sql-port', '3307');
const PASSWORD = arg('sql-password', 'rootpw');
const DB = 'wekanhistory';
const TIMING_ONLY = process.argv.includes('--timing');
function sql(query, { db = DB, allowError = false } = {}) {
try {
return execFileSync('docker', ['run', '--rm', '--network', 'host', 'mysql:9', 'mysql',
'-h', '127.0.0.1', '-P', PORT, '-u', 'root', `-p${PASSWORD}`, '-B', '-N', '-D', db, '-e', query],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
} catch (e) {
if (allowError) return `ERROR: ${String(e.stderr || e.message).replace(/mysql: \[Warning\][^\n]*\n/, '').trim()}`;
throw e;
}
}
const commits = () => Number(sql('SELECT COUNT(*) FROM dolt_log'));
const head = () => sql("SELECT commit_hash FROM dolt_log LIMIT 1");
const say = (line = '') => console.log(line);
const json = (v) => { try { return JSON.parse(v); } catch { return v; } };
async function main() {
const client = await new MongoClient(URI).connect();
const db = client.db(DB);
await db.dropDatabase().catch(() => {});
// Setup the way WeKan has it: a board, two lists and some cards.
const boards = db.collection('boards');
const lists = db.collection('lists');
const cards = db.collection('cards');
await boards.insertOne({ _id: 'b1', title: 'Board', archived: false });
await lists.insertMany([{ _id: 'l1', boardId: 'b1', title: 'To do' }, { _id: 'l2', boardId: 'b1', title: 'Done' }]);
await cards.insertMany([
{ _id: 'c1', boardId: 'b1', listId: 'l1', title: 'Write docs', sort: 1, archived: false },
{ _id: 'c2', boardId: 'b1', listId: 'l1', title: 'Fix bug', sort: 2, archived: false },
]);
const table = sql(`SELECT table_name FROM information_schema.tables WHERE table_schema='${DB}' AND table_name LIKE 'cards%' LIMIT 1`);
if (TIMING_ONLY) {
const N = 200;
const t0 = process.hrtime.bigint();
for (let i = 0; i < N; i += 1) await cards.updateOne({ _id: 'c1' }, { $set: { title: `Edit ${i}` } });
const ms = Number(process.hrtime.bigint() - t0) / 1e6 / N;
say(`updateOne: ${ms.toFixed(2)} ms per call, ${commits()} commits, dolt_transaction_commit=${sql('SELECT @@dolt_transaction_commit', { db: 'information_schema' })}`);
await client.close();
return;
}
say('# 1. A Dolt commit per FerretDB write');
const ops = [
['edit title', () => cards.updateOne({ _id: 'c1' }, { $set: { title: 'Write docs v2' } })],
['move to Done', () => cards.updateOne({ _id: 'c1' }, { $set: { listId: 'l2', sort: 5 } })],
['archive (soft delete)', () => cards.updateOne({ _id: 'c1' }, { $set: { archived: true } })],
['hard delete c2', () => cards.deleteOne({ _id: 'c2' })],
];
const hashes = {};
for (const [name, run] of ops) {
const before = commits();
await run();
hashes[name] = head();
say(`${name.padEnd(24)} commits +${commits() - before} ${hashes[name].slice(0, 10)}`);
}
say(sql("SELECT CONCAT(committer, ' | ', date, ' | ', message) FROM dolt_log LIMIT 4"));
say('\n# 2. Who changed what: dolt_diff of the cards table, as WeKan\'s History table would show it');
const diff = sql(`SELECT diff_type, to_commit, COALESCE(JSON_UNQUOTE(JSON_EXTRACT(from__ferretdb_sjson, '$.title')), ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(to__ferretdb_sjson, '$.title')), ''), ` +
`COALESCE(JSON_UNQUOTE(JSON_EXTRACT(from__ferretdb_sjson, '$.listId')), ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(to__ferretdb_sjson, '$.listId')), '') ` +
`FROM dolt_diff_${table} ORDER BY to_commit_date, to_commit`, { allowError: true });
say('diff_type | commit | title from -> to | list from -> to');
say(diff.split('\n').map((l) => { const c = l.split('\t'); return `${c[0]} | ${String(c[1]).slice(0, 8)} | ${c[2]} -> ${c[3]} | ${c[4]} -> ${c[5]}`; }).join('\n'));
say('\n# 3. Undo the last change (WeKan: Ctrl+Z) = DOLT_REVERT of HEAD, then redo = revert the revert');
const exists = async (id) => !!(await cards.findOne({ _id: id }));
say(`c2 exists before undo: ${await exists('c2')}`);
say(sql("CALL DOLT_REVERT('HEAD')", { allowError: true }));
say(`c2 exists after undo: ${await exists('c2')} (read through FerretDB, the MongoDB protocol)`);
say(sql("CALL DOLT_REVERT('HEAD')", { allowError: true }));
say(`c2 exists after redo: ${await exists('c2')}`);
say(`commits now: ${commits()} - an undo and a redo are commits of their own, the history is never rewritten`);
say('\n# 4. Restore ONE older change while later ones stay (WeKan: Restore of a selected History row)');
await cards.insertOne({ _id: 'c3', boardId: 'b1', listId: 'l1', title: 'Other card', sort: 3, archived: false });
await cards.updateOne({ _id: 'c3' }, { $set: { title: 'Other card v2' } });
const c3edit = head();
await cards.updateOne({ _id: 'c2x', boardId: 'b1' }, { $set: { title: 'x' } }, { upsert: true });
say('revert an old edit of c3 after an unrelated later commit (different row):');
say(sql(`CALL DOLT_REVERT('${c3edit}')`, { allowError: true }));
say(`c3 title: ${(await cards.findOne({ _id: 'c3' })).title}`);
say('revert the OLD title edit of c1 while c1 was changed again afterwards (same row, other field):');
say(sql(`CALL DOLT_REVERT('${hashes['edit title']}')`, { allowError: true }));
const c1 = await cards.findOne({ _id: 'c1' });
say(`c1 now: title=${c1.title} listId=${c1.listId} archived=${c1.archived}`);
say(`left behind by the failed revert: ${sql('SELECT COUNT(*) FROM dolt_conflicts', { allowError: true })} conflict table(s), status: ${sql("SELECT GROUP_CONCAT(CONCAT(table_name, ':', status)) FROM dolt_status", { allowError: true })}`);
const stuck = await cards.updateOne({ _id: 'c1' }, { $set: { sort: 99 } }).then(() => 'a write still works', (e) => `a write FAILS: ${String(e.message).slice(0, 120)}`);
say(`FerretDB write while the conflict is open: ${stuck}`);
say(`clean up with DOLT_RESET('--hard'): ${sql("CALL DOLT_RESET('--hard')", { allowError: true })}`);
const after = await cards.updateOne({ _id: 'c1' }, { $set: { sort: 100 } }).then(() => 'a write works again', (e) => `a write FAILS: ${String(e.message).slice(0, 120)}`);
say(`FerretDB write after the reset: ${after}`);
say('\n# 5. Time travel: the board as it was (AS OF), read with SQL');
const asOf = sql(`SELECT COUNT(*) FROM ${table} AS OF '${hashes['move to Done']}'`, { allowError: true });
const nowCount = sql(`SELECT COUNT(*) FROM ${table}`);
say(`cards at the "move to Done" commit: ${asOf}; now: ${nowCount}`);
const oldTitle = sql(`SELECT JSON_UNQUOTE(JSON_EXTRACT(_ferretdb_sjson, '$.title')) FROM ${table} AS OF '${hashes['edit title']}' WHERE JSON_UNQUOTE(JSON_EXTRACT(_ferretdb_sjson, '$._id')) = 'c1'`, { allowError: true });
say(`c1 title as of the "edit title" commit: ${oldTitle}`);
say('\n# 6. What it costs');
say(`commits for ${ops.length + 6} FerretDB writes: ${commits()}`);
say(`size of the data database: ${sql(`SELECT COUNT(*) FROM dolt_log`)} commits; chunks are shared, only changed rows are stored again`);
await client.close();
}
main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,95 @@
'use strict';
// The same questions as dolt-history.cjs, asked of TerminusDB (a document and
// graph database with version control). TerminusDB speaks neither the MongoDB
// protocol nor SQL, so FerretDB cannot sit on it and WeKan cannot use it as it
// is; this only shows what its version control would give an undo and redo.
//
// docker run -d --name tdbtry -p 6363:6363 terminusdb/terminusdb-server:latest
// node docs/Databases/FerretDB/1/VersionHistory/terminusdb-history.cjs
const BASE = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://127.0.0.1:6363';
const AUTH = `Basic ${Buffer.from('admin:root').toString('base64')}`;
const DB = 'wekanhistory';
const say = (line = '') => console.log(line);
async function api(method, path, { query = {}, body, raw = false } = {}) {
const url = new URL(`${BASE}${path}`);
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
const res = await fetch(url, {
method, headers: { Authorization: AUTH, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let data = text;
try { data = JSON.parse(text); } catch { try { data = text.trim().split('\n').map(JSON.parse); } catch { /* text */ } }
return raw ? { status: res.status, data } : { status: res.status, data };
}
const w = (author, message) => ({ author, message });
const short = (v) => JSON.stringify(v).slice(0, 220);
async function main() {
await api('DELETE', `/api/db/admin/${DB}`);
const created = await api('POST', `/api/db/admin/${DB}`, { body: { label: DB, comment: 'WeKan history test' } });
say(`create database: ${created.status}`);
const schema = [{
'@type': 'Class', '@id': 'Card', '@key': { '@type': 'Lexical', '@fields': ['cardId'] },
cardId: 'xsd:string', title: 'xsd:string', listId: 'xsd:string', sort: 'xsd:integer', archived: 'xsd:boolean',
}];
const s = await api('POST', `/api/document/admin/${DB}`, { query: { graph_type: 'schema', ...w('system', 'schema') }, body: schema });
say(`schema: ${s.status} ${s.status === 200 ? '' : short(s.data)}`);
const card = (id, title, listId, sort, archived = false) => ({ '@type': 'Card', cardId: id, title, listId, sort, archived });
const log = async () => (await api('GET', `/api/log/admin/${DB}`)).data;
say('\n# 1. A commit per write, with the author and message of the person who made it');
const ins = await api('POST', `/api/document/admin/${DB}`, { query: w('alice', 'add cards'), body: [card('c1', 'Write docs', 'l1', 1), card('c2', 'Fix bug', 'l1', 2)] });
say(`insert: ${ins.status} ${short(ins.data)}`);
const hashes = {};
const ops = [
['edit title', 'alice', () => api('PUT', `/api/document/admin/${DB}`, { query: w('alice', 'edit title'), body: card('c1', 'Write docs v2', 'l1', 1) })],
['move to Done', 'bob', () => api('PUT', `/api/document/admin/${DB}`, { query: w('bob', 'move to Done'), body: card('c1', 'Write docs v2', 'l2', 5) })],
['archive', 'carol', () => api('PUT', `/api/document/admin/${DB}`, { query: w('carol', 'archive'), body: card('c1', 'Write docs v2', 'l2', 5, true) })],
['hard delete c2', 'bob', () => api('DELETE', `/api/document/admin/${DB}`, { query: { ...w('bob', 'hard delete c2'), id: 'Card/c2' } })],
];
for (const [name, , run] of ops) {
const before = (await log()).length;
const r = await run();
const l = await log();
hashes[name] = l[0].identifier;
say(`${name.padEnd(16)} HTTP ${r.status} commits +${l.length - before} ${l[0].author} | ${l[0].message}`);
}
say((await log()).slice(0, 5).map((c) => `${c.author} | ${c.message} | ${new Date(c.timestamp * 1000).toISOString()} | ${String(c.identifier).slice(0, 10)}`).join('\n'));
say('\n# 2. Time travel: the cards as of an earlier commit');
const asOf = async (commit) => (await api('GET', `/api/document/admin/${DB}/local/commit/${commit}`, { query: { type: 'Card', as_list: 'true' } })).data;
const nowCards = (await api('GET', `/api/document/admin/${DB}`, { query: { type: 'Card', as_list: 'true' } })).data;
say(`now: ${nowCards.length} cards; as of "move to Done": ${short(await asOf(hashes['move to Done']))}`);
say('\n# 3. Undo the last change (WeKan: Ctrl+Z): a diff between two commits, applied backwards');
const lastTwo = (await log()).slice(0, 2);
const diffBack = await api('POST', `/api/diff/admin/${DB}`, { body: { before_data_version: lastTwo[0].identifier, after_data_version: lastTwo[1].identifier } });
say(`diff (hard delete -> before it): ${diffBack.status} ${short(diffBack.data)}`);
const patched = await api('POST', `/api/patch/admin/${DB}`, { body: { patch: diffBack.data, ...w('alice', 'undo: hard delete c2') } });
say(`patch: ${patched.status} ${short(patched.data)}`);
say(`c2 exists after undo: ${(await api('GET', `/api/document/admin/${DB}`, { query: { id: 'Card/c2' } })).status === 200}`);
say('\n# 4. Restore ONE older change while later ones stay (WeKan: Restore of a selected History row)');
// The "edit title" commit and the one before it.
const l = await log();
const idx = l.findIndex((c) => c.identifier === hashes['edit title']);
const titleBack = await api('POST', `/api/diff/admin/${DB}`, { body: { before_data_version: l[idx].identifier, after_data_version: l[idx + 1].identifier } });
say(`diff to undo the title edit: ${short(titleBack.data)}`);
const applied = await api('POST', `/api/patch/admin/${DB}`, { body: { patch: titleBack.data, ...w('alice', 'restore: title') } });
say(`patch onto HEAD, where c1 was moved and archived afterwards: ${applied.status} ${short(applied.data)}`);
const c1 = (await api('GET', `/api/document/admin/${DB}`, { query: { id: 'Card/c1' } })).data;
say(`c1 now: ${short(c1)}`);
say('\n# 5. What it costs');
const t0 = Date.now();
for (let i = 0; i < 100; i += 1) await api('PUT', `/api/document/admin/${DB}`, { query: w('alice', `edit ${i}`), body: card('c1', `Edit ${i}`, 'l2', 5) });
say(`PUT of a card: ${((Date.now() - t0) / 100).toFixed(1)} ms per call (an HTTP call each, a commit each); commits: ${(await log()).length}`);
}
main().catch((e) => { console.error(e); process.exit(1); });
+14 -2
View File
@@ -82,8 +82,20 @@ early.
## What the bar holds
In source order: the house and the page title, the logo, the phone/desktop
toggle, the drag-handles toggle, and then everything else.
In source order: the house and the page title, the logo, the full screen button,
the phone/desktop toggle, the drag-handles toggle, and then everything else.
**Full screen.** A button to the right of the logo puts the whole page in full
screen and takes it out again, with the Fullscreen API (`models/lib/fullscreen.js`):
the icon is an expand arrow, and a compress arrow while the page is in full screen,
and it follows the browser's own way out (Esc) too. Its label is the existing
"Maximize", and "Minimize" while the page is in full screen, so the same button
says how to leave (Esc and the browser's own control work too). It is drawn only where the browser offers the API and the page may use
it - iPad Safari, desktop browsers and Android browsers do; **iPhone Safari does not**
(the API is for video there) and neither does a page inside an `<iframe>` without
`allowfullscreen`, so no dead button is shown. On an iPhone, "Add to Home Screen"
opens WeKan without the browser's bars (the manifest says `display: standalone`).
It sits before the first icon the fold button collects, so folding the icons keeps it.
**The board's controls fold.** A caret leads the group, at its leading edge —
left of the lock in a left-to-right page, right of it in Arabic. Folded, the
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-12.90-win64.zip](https://github.com/wekan/wekan/releases/download/v12.90/wekan-12.90-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-12.94-win64.zip](https://github.com/wekan/wekan/releases/download/v12.94/wekan-12.94-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-12.90-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-12.94-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+2
View File
@@ -76,6 +76,7 @@ runOnServer(function() {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -96,6 +97,7 @@ runOnServer(function() {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
+4
View File
@@ -100,6 +100,7 @@ runOnServer(function() {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -129,6 +130,7 @@ runOnServer(function() {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
@@ -199,6 +201,7 @@ runOnServer(function() {
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -218,6 +221,7 @@ runOnServer(function() {
const loginToken = req.query && req.query.authToken;
if (loginToken) {
if (loginToken.length > 10000) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
+25
View File
@@ -0,0 +1,25 @@
'use strict';
// Whether this installation lets people upload attachments (#6870).
//
// The switch already exists: Admin Panel / Attachments / Transfer limits has "Attachments upload"
// and "API upload" set to Blocked (limitSettings.attachmentsUploadBlocked / apiUploadBlocked).
// The server refuses an upload on every path when it is on - the upload pipeline
// (models/attachments.server.js) and the API methods and routes (server/attachmentApi.js,
// server/routes/attachmentApi.js). What was missing is that the card still offered the "+" button
// and a drop zone for an upload that would be refused. This is the one decision the server
// method that tells the client and the tests share, so there is no second setting that could
// disagree with the one the server enforces.
//
// Only the boolean leaves the server: the rest of the limits (sizes, storage paths) are for
// administrators.
function uploadPolicy(limitSettings) {
const limits = limitSettings && typeof limitSettings === 'object' ? limitSettings : {};
return {
// anything but a real `true` keeps uploads as they were
uploadsBlocked: limits.attachmentsUploadBlocked === true,
};
}
module.exports = { uploadPolicy };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// Which server streams the continuous backup (#6861, item 3).
//
// The continuous backup tails the oplog and writes archives into one target directory. With several
// WeKan servers on one database - the replicas of a Kubernetes Deployment - every one of them applied
// the setting, so the same stream was written many times into one place. It needs a leader: one
// server runs the stream, and another takes over when it stops.
//
// The leader holds a LEASE: one document, `continuousBackupLease/continuous-backup`, with the
// holder's id and an expiry. Every decision is ONE database operation, so there is no gap between
// "is it free" and "it is mine" for a second server to slip through (the pattern of
// models/lib/dailyClaim.js and dueScanClaim.js):
// - acquireLease: an insert while there is no lease at all (a duplicate key says someone has it),
// else an update whose filter matches only a lease that is unheld, already ours, or expired;
// - the holder renews it every RENEW_MS, and it lapses TTL_MS after the last renewal, so a leader
// that crashed, was rolled out or lost its database for a minute is replaced by the next server
// to look - without waiting for anybody to notice.
// No upsert on purpose: an upsert whose filter does not match has to insert, and what a database
// does then (a duplicate key, or a second document) is the very thing the lease must not depend on.
//
// `collection` is a MongoDB driver collection (insertOne, updateOne).
const LEASE_ID = 'continuous-backup';
const RENEW_MS = 15 * 1000;
const TTL_MS = 45 * 1000;
const isDuplicateKey = error => !!error && (error.code === 11000 || /E11000|duplicate key/i.test(String(error.message || '')));
// Resolves true when `holder` leads from now until the lease expires.
async function acquireLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
const expiresAt = new Date(now.getTime() + ttlMs);
try {
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
return true;
} catch (error) {
if (!isDuplicateKey(error)) throw error;
}
const result = await collection.updateOne(
{ _id: LEASE_ID, $or: [{ holder }, { holder: null }, { expiresAt: { $lte: now } }] },
{ $set: { holder, expiresAt } },
);
return (result.matchedCount || 0) > 0 || (result.modifiedCount || 0) > 0;
}
// Gives the lease up when this server is the holder (a clean stop, or the feature switched off), so
// the next server does not have to wait for it to expire.
async function releaseLease(collection, holder) {
await collection.updateOne({ _id: LEASE_ID, holder }, { $set: { holder: null, expiresAt: new Date(0) } });
}
// Takes the lease whoever holds it. For a restore, which must be the only writer: the former leader
// finds at its next renewal that it lost the lease and stops, so the caller then waits RENEW_MS
// before it writes anything (see waitMs).
async function takeoverLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
const expiresAt = new Date(now.getTime() + ttlMs);
try {
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
return { waitMs: 0 };
} catch (error) {
if (!isDuplicateKey(error)) throw error;
}
const result = await collection.updateOne({ _id: LEASE_ID }, { $set: { holder, expiresAt } });
// taking a lease this server already held needs no wait: nobody else was streaming
return { waitMs: RENEW_MS, matched: (result.matchedCount || 0) > 0 };
}
module.exports = { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
// Full screen for the whole page, with the unprefixed Fullscreen API and the
// webkit-prefixed one older Safari (and iPadOS Safari before 16.4) has. Pure and
// Meteor-free, the document is passed in, so plain Node tests drive it with a
// fake one.
//
// Where it is NOT available the button must not be drawn at all:
// - iPhone Safari (and every iPhone browser, they are all WebKit) has the API for
// video only: document.fullscreenEnabled is false or missing;
// - a page inside an <iframe> without allowfullscreen (a Sandstorm grain, say) has
// fullscreenEnabled false;
// - a very old browser has neither name.
// "Add to Home Screen" (the manifest says display: standalone) is the way there.
function isSupported(doc) {
if (!doc) return false;
return doc.fullscreenEnabled === true || doc.webkitFullscreenEnabled === true;
}
function currentElement(doc) {
return (doc && (doc.fullscreenElement || doc.webkitFullscreenElement)) || null;
}
function isFullscreen(doc) {
return currentElement(doc) !== null;
}
// Enters full screen, or leaves it. Resolves true when the page is in full screen
// afterwards, false when it is not; it never rejects - a browser that refuses (no
// user gesture, a policy) is an answer, not an error to show. The request has to
// come from a click or a key press, which is what the button is.
async function toggle(doc) {
if (!isSupported(doc)) return false;
try {
if (isFullscreen(doc)) {
const exit = doc.exitFullscreen || doc.webkitExitFullscreen;
if (exit) await exit.call(doc);
} else {
const el = doc.documentElement;
const request = el && (el.requestFullscreen || el.webkitRequestFullscreen);
if (request) await request.call(el);
}
} catch (e) {
// refused: the state below says what happened
}
return isFullscreen(doc);
}
// The events a change arrives on, both spellings.
const CHANGE_EVENTS = ['fullscreenchange', 'webkitfullscreenchange'];
module.exports = { isSupported, isFullscreen, toggle, CHANGE_EVENTS };
+58
View File
@@ -0,0 +1,58 @@
'use strict';
// Moving a window - the opened card, a popup - with a finger as well as a mouse.
// The decisions only, apart from the DOM, so plain Node tests can drive them.
//
// A mouse sends mousedown / mousemove / mouseup; a finger sends none of them while it
// moves (only a click after a tap), so a window moved by those events could not be
// moved on an iPad at all, while the page behind it scrolled. Pointer Events carry
// both, and a drag area also needs `touch-action: none`, or the browser takes the
// gesture for a scroll and cancels the pointer.
const TOUCH_THRESHOLD = 8; // px a finger may wobble in a tap
const MOUSE_THRESHOLD = 4;
// What a press on the card's header row does. All booleans are about the pressed
// element:
// onEditable an input, textarea, select or option, or the open title editor:
// the browser keeps those (text selection, caret)
// onTitleEditZone the title, which edits - all of it
// onHandle the drag handle
// cardHasHandle drag handles are on for this card: the handle is then the only
// thing that moves the window
// onControl a link or button (the collapse caret, close, maximize, menu):
// a tap does its job, a DRAG moves the window
// Returns { start, deferred }: whether the press can move the window, and whether it
// waits for the finger or mouse to move first (so a tap on a control still works).
function cardHeaderPress({ onEditable, onTitleEditZone, onHandle, cardHasHandle, onControl }) {
if (onHandle) return { start: true, deferred: false };
if (onEditable || onTitleEditZone) return { start: false, deferred: false };
if (cardHasHandle) return { start: false, deferred: false };
return { start: true, deferred: !!onControl };
}
function dragThreshold(pointerType, deferred) {
if (!deferred) return 0;
return pointerType === 'mouse' ? MOUSE_THRESHOLD : TOUCH_THRESHOLD;
}
function passedThreshold(dx, dy, threshold) {
return Math.hypot(dx, dy) >= threshold;
}
// Keeps a window where it can be found again: at least `keep` px of its top-left
// strip stays inside the viewport, so its header can always be grabbed.
function clampWindow(left, top, width, viewport, keep = 48) {
const minLeft = keep - width;
const maxLeft = viewport.width - keep;
const maxTop = viewport.height - keep;
return {
left: Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft)),
top: Math.min(Math.max(top, 0), Math.max(0, maxTop)),
};
}
module.exports = { cardHeaderPress, dragThreshold, passedThreshold, clampWindow, TOUCH_THRESHOLD, MOUSE_THRESHOLD };
+12 -5
View File
@@ -44,6 +44,12 @@
"lodash.uniq": "^4.5.0"
}
},
"node_modules/@isaacs/nonrecursive-glob-match": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
"license": "BlueOak-1.0.0"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -654,15 +660,16 @@
"license": "MIT"
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
+5
View File
@@ -94,5 +94,10 @@
"tmp": "^0.2.5",
"unzipper": "^0.12.0",
"uuid": "^14.0.0"
},
"overrides": {
"readdir-glob": {
"minimatch": "^11.0.1"
}
}
}
+25 -15
View File
@@ -412,6 +412,13 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@isaacs/nonrecursive-glob-match": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -3556,16 +3563,17 @@
}
},
"node_modules/nyc/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -4373,16 +4381,17 @@
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -5236,16 +5245,17 @@
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -61,5 +61,13 @@
"_id": "jade@1.3.0",
"_shasum": "1d2afc59de1a0ee9c06d3b364833ac0d009886ac",
"_from": "https://github.com/mquandalle/jade/tarball/f3f956fa1031e05f85be7bc7b67f12e9ec80ba37",
"_resolved": "https://github.com/mquandalle/jade/tarball/f3f956fa1031e05f85be7bc7b67f12e9ec80ba37"
"_resolved": "https://github.com/mquandalle/jade/tarball/f3f956fa1031e05f85be7bc7b67f12e9ec80ba37",
"overrides": {
"glob@^13.0.0": {
"minimatch": "^11.0.1"
},
"test-exclude": {
"minimatch": "^11.0.1"
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v12.90.0",
"version": "v12.94.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v12.90.0",
"version": "v12.94.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v12.90.0",
"version": "v12.94.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v12.90</title>
<title>WeKan REST API v12.94</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
<body>
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<script type="module">
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.90","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.94","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v12.90
version: v12.94
description: |
The REST API allows you to control and extend Wekan with ease.
+19 -4
View File
@@ -155,14 +155,20 @@ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-bundle-bump-'));
const bumped = [];
const skipped = [];
for (const [name, minimum] of Object.entries(manifest.minimums)) {
const outdated = copiesOf(name).filter(c => compare(c.version, minimum) < 0);
for (const [key, minimum] of Object.entries(manifest.minimums)) {
// "body-parser@2" limits the minimum to copies of that major; "@babel/runtime"
// is a scoped name, not a major, so only an @ after the first character counts.
const at = key.lastIndexOf('@');
const name = at > 0 ? key.slice(0, at) : key;
const major = at > 0 ? key.slice(at + 1) : null;
const outdated = copiesOf(name).filter(c =>
(major === null || c.version.split('.')[0] === major) && compare(c.version, minimum) < 0);
if (!outdated.length) {
skipped.push(name);
skipped.push(key);
continue;
}
const prefix = path.join(tmpRoot, name.replace('/', '+'));
const prefix = path.join(tmpRoot, key.replace(/[\/@]/g, '+') + '-' + minimum);
if (!dryRun) {
fs.mkdirSync(prefix, { recursive: true });
// A prefix with no package.json is what we want: npm installs the one
@@ -180,6 +186,15 @@ for (const [name, minimum] of Object.entries(manifest.minimums)) {
fs.rmSync(copy.dir, { recursive: true, force: true });
fs.cpSync(path.join(prefix, 'node_modules', name), copy.dir, { recursive: true });
// The entry file the bundle recorded for the OLD version, written back so it
// resolves again (uuid 11 moved dist/index.js to dist/cjs/index.js).
for (const [rel, body] of Object.entries((manifest.shims || {})[name] || {})) {
const file = path.join(copy.dir, rel);
if (fs.existsSync(file)) continue;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
console.log(` + ${rel} (shim for the entry point ${name} ${copy.version} was built against)`);
}
// Whatever the new version needs that this node_modules does not have. An
// existing sibling is left as it is - it is some other package's choice.
+30 -32
View File
@@ -7,10 +7,20 @@
"be raised is the bundle, after `meteor build` has produced it.",
"",
"Every entry is a version that FIXES a published advisory in the version the",
"bundle would otherwise ship, and stays INSIDE THE SAME MAJOR. That is not",
"caution for its own sake: a Meteor bundle records the file a package resolves",
"to when it is built, so a major that moves its entry point cannot be swapped",
"in afterwards - see uuid under notFixable, which is what taught us that.",
"bundle would otherwise ship. A minimum stays inside the same major WHEN a fix",
"exists there. When the fix is only in a newer major it is still taken, but only",
"after the new major was run the way the Meteor package uses it: nodemailer 10",
"against the calls meteor/email makes, uuid 11 through a shim (see \"shims\").",
"",
"A key may be \"name@MAJOR\" (body-parser@2): the minimum then applies only to",
"copies of that major, so body-parser 1.x and 2.x are each raised inside their",
"own major and neither is dragged across it.",
"",
"A bundle records the file a package resolves to when it is built, so a major",
"that moved its entry point cannot just be swapped in: uuid 11 moved it from",
"dist/index.js to dist/cjs/index.js and the server died on boot (\"Cannot find",
"module .../uuid/dist/index.js\"). \"shims\" writes the old entry file back,",
"re-exporting the new one.",
"",
"Applied by releases/bump-bundle-npm-deps.mjs, pinned by",
"tests/bundleNpmSecurityBumps.test.cjs. A package NOT listed here is a package",
@@ -21,7 +31,8 @@
"minimums": {
"@babel/runtime": "7.29.7",
"baseline-browser-mapping": "2.11.0",
"body-parser": "1.20.6",
"body-parser": "1.20.9",
"body-parser@2": "2.4.0",
"browserslist": "4.28.7",
"colord": "2.9.4",
"compression": "1.8.2",
@@ -30,43 +41,30 @@
"lodash": "4.18.1",
"lodash.template": "4.18.1",
"nanoid": "3.3.18",
"nodemailer": "8.0.11",
"nodemailer": "10.3.1",
"on-headers": "1.1.0",
"openpgp": "5.11.3",
"picomatch": "4.0.5",
"postcss": "8.5.26",
"qs": "6.16.0",
"postcss-selector-parser": "6.1.5",
"qs": "6.16.1",
"source-map-js": "1.2.2",
"svgo": "2.8.4",
"tmp": "0.2.7",
"underscore": "1.13.8"
"underscore": "1.13.8",
"uuid": "11.1.1"
},
"notFixable": {
"nodemailer": [
"8.0.11 is the newest 8.x. CVE-2026-82659 (fixed in 9.0.1), GHSA-2x7j-588g-ccc2,",
"GHSA-cc9r-2j5m-2m83 and GHSA-wmmp-3585-3rmp (9.1.0), GHSA-8m3c-c648-2xjj (9.1.1),",
"GHSA-6vj9-mwq6-2f5v and GHSA-8vvx-rff5-p5rq (10.0.2) and GHSA-v53p-9fqp-m79j",
"(10.0.6) are fixed only in majors the Meteor email package is not written",
"against. They move when meteor/email moves."
],
"postcss-selector-parser": [
"CVE-2026-104844 in the 6.1.4 meteor/minifier-css brings (through cssnano) is",
"fixed only in 7.1.6, a new major its dependents are not written against.",
"minifier-css runs at build time; it moves when Meteor's minifier-css moves."
],
"uuid": [
"CVE-2026-41907 affects the 8.3.2 in meteor/ddp-server and",
"meteor/inter-process-messaging and the 9.0.1 ldapts brings to wekan-ldap,",
"and the lowest fixed release is 11.1.1 - a major that MOVED its entry",
"point from dist/index.js to dist/cjs/index.js. The bundle resolves the old",
"path, so putting 11.1.1 in its place stops the server before it starts:",
"",
" error on boot.js Error: Cannot find module",
" \"/node_modules/meteor/inter-process-messaging/node_modules/uuid/dist/index.js\"",
"",
"It was tried, it failed exactly there, and it is left at the version the",
"packages were built against. It moves when those packages move: Meteor's",
"for ddp-server and inter-process-messaging, ldapts' for wekan-ldap."
"6.1.5 fixes GHSA-rj75-hqrm-r3gf and is taken. CVE-2026-104844 in the copy",
"meteor/minifier-css brings (through cssnano) is fixed only in 7.1.6, a new major",
"its dependents are not written against. minifier-css runs at build time; it",
"moves when Meteor's minifier-css moves."
]
},
"shims": {
"uuid": {
"dist/index.js": "export * from './esm/index.js';\n"
}
}
}
+67 -1
View File
@@ -101,8 +101,11 @@ BACKENDS=(
"mongodb8|image:mongo:8.0|mongodb|27017|mongodb"
"mongodb9|image:mongo:9.0|mongodb|27017|mongodb"
"postgresql|docker-compose-ferretdb-v1-postgresql.yml|postgres|5432|postgresql"
"pgrust|image:malisper/pgrust:v0.4-beta|pgrust|5432|postgresql"
"mysql|docker-compose-ferretdb-v1-mysql.yml|mysql|3306|mysql"
"mariadb|docker-compose-ferretdb-v1-mariadb.yml|mariadb|3306|mysql"
"tidb|image:pingcap/tidb:latest|tidb|4000|mysql"
"dolt|image:dolthub/dolt-sql-server:latest|dolt|3306|mysql"
"sap-hana|docker-compose-ferretdb-v1-sap-hana.yml|hana|39017|hana"
"ferretdb2|docker-compose-ferretdb-v2-postgresql.yml|postgres-documentdb|5432|ferretdb2"
)
@@ -417,8 +420,19 @@ engine_name() {
echo "MongoDB ${v:-?}"; return ;;
postgresql) v="$(docker_exec exec "$CONTAINER" postgres --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+)+' | head -1)"
v="PostgreSQL ${v:-?}" ;;
# pgrust (https://github.com/malisper/pgrust): PostgreSQL rewritten in Rust,
# wire compatible, so FerretDB's postgresql handler talks to it unchanged.
# `postgres --version` says "pgrust 0.4-beta (PostgreSQL 18.6 compatible)".
pgrust) v="$(docker_exec exec "$CONTAINER" postgres --version 2>/dev/null | head -1)"
v="${v:-pgrust ?}" ;;
mysql) v="$(docker_exec exec "$CONTAINER" mysqld --version 2>/dev/null | grep -oE 'Ver [0-9.]+' | head -1)"
v="MySQL ${v#Ver }" ;;
# `select version()` says 8.0.11-TiDB-v7.5.1. The image has no client, so the
# mysql image's is used, over the host network.
dolt) v="$(docker_exec run --rm --network host mysql:9 mysql -h 127.0.0.1 -P "$hostport" -u root -pferretdb_root_secret -N -e 'select dolt_version()' 2>/dev/null | head -1)"
v="Dolt ${v:-?}" ;;
tidb) v="$(docker_exec run --rm --network host mysql:9 mysql -h 127.0.0.1 -P "$hostport" -u root -N -e 'select version()' 2>/dev/null | grep -oE 'TiDB-v[0-9.]+' | head -1)"
v="TiDB ${v#TiDB-}" ;;
mariadb) v="$(docker_exec exec "$CONTAINER" mariadbd --version 2>/dev/null | grep -oE 'Ver [0-9.]+' | head -1)"
v="MariaDB ${v#Ver }" ;;
sqlite) v="$(grep -m1 -oE 'modernc.org/sqlite v[0-9.]+' "$FERRET_DIR/go.mod" 2>/dev/null)"
@@ -475,6 +489,35 @@ for entry in "${BACKENDS[@]}"; do
continue
fi
# pgrust is a young database (v0.4-beta): in the speed test it is one more row to
# compare, but a conformance case it answers differently is a finding about
# pgrust, not a failed EVERYTHING run - so there it is opt-in, like FerretDB 2.
if [ "$name" = "pgrust" ] && [ "$SUITE" != speedtest ] && [ "${WEKAN_CONFORMANCE_PGRUST:-0}" != "1" ]; then
echo "SKIP pgrust (PostgreSQL in Rust; set WEKAN_CONFORMANCE_PGRUST=1 to include)"
echo "SKIP pgrust not requested" >> "$SUMMARY"
skipped=$((skipped + 1))
continue
fi
# TiDB (MySQL wire, Apache-2.0) is a distributed SQL database run here as one
# process on its own storage; same reasoning as pgrust above.
if [ "$name" = "tidb" ] && [ "$SUITE" != speedtest ] && [ "${WEKAN_CONFORMANCE_TIDB:-0}" != "1" ]; then
echo "SKIP tidb (TiDB, MySQL protocol; set WEKAN_CONFORMANCE_TIDB=1 to include)"
echo "SKIP tidb not requested" >> "$SUMMARY"
skipped=$((skipped + 1))
continue
fi
# Dolt (git for data, MySQL protocol) is run as it comes: without
# dolt_transaction_commit, so it makes no commit per write and is measured as a
# database. Same reasoning as pgrust above.
if [ "$name" = "dolt" ] && [ "$SUITE" != speedtest ] && [ "${WEKAN_CONFORMANCE_DOLT:-0}" != "1" ]; then
echo "SKIP dolt (Dolt, MySQL protocol; set WEKAN_CONFORMANCE_DOLT=1 to include)"
echo "SKIP dolt not requested" >> "$SUMMARY"
skipped=$((skipped + 1))
continue
fi
# SAP HANA only on purpose (see the note at the top).
if [ "$name" = "sap-hana" ] && [ "${WEKAN_CONFORMANCE_HANA:-0}" != "1" ]; then
echo "SKIP sap-hana (needs ~16 GB RAM and SAP's licence; set WEKAN_CONFORMANCE_HANA=1 to include)"
@@ -565,7 +608,9 @@ for entry in "${BACKENDS[@]}"; do
SQLITE_DATA="$(mktemp -d "$WEKAN_DIR/.tools/tmp/$PREFIX-sqlite.XXXXXX")"
url="file:$SQLITE_DATA/"
;;
postgresql)
postgresql|pgrust)
# pgrust's image is a drop-in for postgres: the same variables and the same
# data directory, initialised by pgrust's own initdb.
start_db_container \
-e POSTGRES_USER=ferretdb -e POSTGRES_PASSWORD=ferretdb_secret \
-e POSTGRES_DB=ferretdb "$image" \
@@ -590,6 +635,22 @@ for entry in "${BACKENDS[@]}"; do
# database per MongoDB database, which a per-database grant cannot allow.
url="mysql://root:ferretdb_root_secret@127.0.0.1:$hostport/ferretdb"
;;
dolt)
# DOLT_ROOT_HOST=% lets root in from outside the container; the image
# creates the database FerretDB opens.
start_db_container -e DOLT_ROOT_PASSWORD=ferretdb_root_secret -e DOLT_ROOT_HOST=% \
-e DOLT_DATABASE=ferretdb "$image" \
|| { echo "ERROR $name container did not start" >> "$SUMMARY"; stop_on_failure; continue; }
url="mysql://root:ferretdb_root_secret@127.0.0.1:$hostport/ferretdb"
;;
tidb)
# One process with its own storage (unistore), no PD or TiKV: enough for a
# single-node comparison. It has no MYSQL_DATABASE variable, so the database
# FerretDB opens is created below, once it answers.
start_db_container "$image" --store=unistore --path="" --host=0.0.0.0 \
|| { echo "ERROR $name container did not start" >> "$SUMMARY"; stop_on_failure; continue; }
url="mysql://root@127.0.0.1:$hostport/ferretdb"
;;
mongodb*)
start_db_container "$image" \
|| { echo "ERROR $name container did not start" >> "$SUMMARY"; stop_on_failure; continue; }
@@ -628,12 +689,17 @@ for entry in "${BACKENDS[@]}"; do
# confusing connection error.
case "$name" in
postgresql) docker_exec exec "$CONTAINER" pg_isready -U ferretdb -d ferretdb >/dev/null 2>&1 && up=1 ;;
pgrust) docker_exec exec "$CONTAINER" psql -U ferretdb -d ferretdb -tAc 'SELECT 1' 2>/dev/null | grep -q 1 && up=1 ;;
# Ready once the DocumentDB extension is installed, not merely accepting
# connections - the entrypoint restarts the server after its init scripts.
ferretdb2) docker_exec exec "$CONTAINER" psql -U ferretdb -d postgres -tAc \
"SELECT 1 FROM pg_extension WHERE extname = 'documentdb'" 2>/dev/null | grep -q 1 && up=1 ;;
mysql) docker_exec exec "$CONTAINER" mysqladmin ping -h 127.0.0.1 -u root -pferretdb_root_secret --silent >/dev/null 2>&1 && up=1 ;;
mariadb) docker_exec exec "$CONTAINER" healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1 && up=1 ;;
dolt) docker_exec run --rm --network host mysql:9 mysql -h 127.0.0.1 -P "$hostport" -u root -pferretdb_root_secret \
-D ferretdb -e 'SELECT 1' >/dev/null 2>&1 && up=1 ;;
tidb) docker_exec run --rm --network host mysql:9 mysql -h 127.0.0.1 -P "$hostport" -u root \
-e 'CREATE DATABASE IF NOT EXISTS ferretdb' >/dev/null 2>&1 && up=1 ;;
sap-hana) docker_exec logs "$CONTAINER" 2>&1 | grep -q "Startup finished" && up=1 ;;
mongodb*)
docker_exec exec "$CONTAINER" mongosh --quiet --eval 'db.runCommand({ping:1}).ok' 2>/dev/null | grep -q 1 && up=1 ;;
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1290,
appVersion = 1294,
# Increment this for every release.
appMarketingVersion = (defaultText = "12.90~2026-11-03"),
appMarketingVersion = (defaultText = "12.94~2026-11-04"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+73 -8
View File
@@ -6,6 +6,7 @@
import { Meteor } from 'meteor/meteor';
import { check, Match } from 'meteor/check';
import { Mongo, MongoInternals } from 'meteor/mongo';
import { Random } from 'meteor/random';
import { ReactiveCache } from '/imports/reactiveCache';
import { ZipArchive } from 'archiver';
import path from 'path';
@@ -31,10 +32,18 @@ function cloudRemote(provider) {
return adapter ? adapterRemote(adapter.storage, adapter.bucketName) : null;
}
const { sanitizeDetail } = require('/models/lib/securityLogFormat');
const { RENEW_MS, acquireLease, releaseLease, takeoverLease } = require('/models/lib/backupLease');
export const ContinuousBackupSettings = new Mongo.Collection('continuousBackupSettings');
ContinuousBackupSettings.deny({ insert: () => true, update: () => true, remove: () => true });
// #6861: with several servers on one database only one streams (models/lib/backupLease.js).
export const ContinuousBackupLease = new Mongo.Collection('continuousBackupLease');
ContinuousBackupLease.deny({ insert: () => true, update: () => true, remove: () => true });
const leaseCollection = () => ContinuousBackupLease.rawCollection();
// This server's name in the lease.
const serverId = Random.id();
const writablePath = () => path.resolve(process.env.WRITABLE_PATH || process.cwd());
const filesRoot = () => filesRootFrom(writablePath());
// The FerretDB SQLite directory, from the environment the startup scripts
@@ -78,6 +87,55 @@ async function requireSiteAdmin(userId) {
}
let restoring = false;
// The leader loop. Every server with the feature switched on runs it: the one holding the lease
// streams, the others stand by and one of them takes over within about a minute when the leader
// stops renewing (a pod restart, a rollout, a crash). A single server is always its own leader.
let leading = false;
let appliedStamp = null;
let leaseTimer = null;
const stampOf = settings => String(settings.updatedAt ? new Date(settings.updatedAt).getTime() : 0);
async function evaluateLease() {
if (restoring) return;
try {
const saved = await ContinuousBackupSettings.findOneAsync('settings');
const settings = await savedSettings();
if (!settings.enabled) {
if (backupManager().status().running) await backupManager().stop();
if (leading) await releaseLease(leaseCollection(), serverId);
leading = false; appliedStamp = null;
return;
}
leading = await acquireLease(leaseCollection(), serverId);
if (!leading) {
// someone else streams: stop ours, if it was running, so two never write one target
if (backupManager().status().running) await backupManager().stop();
appliedStamp = null;
return;
}
const stamp = stampOf(saved || {});
// start the stream, or follow a settings change another server saved
if (!backupManager().status().running || stamp !== appliedStamp) {
await backupManager().apply(settings);
appliedStamp = stamp;
}
} catch (error) { reportProblem('lease', error); }
}
function startLeaseLoop() {
if (leaseTimer) return;
leaseTimer = Meteor.setInterval(() => { evaluateLease(); }, RENEW_MS);
}
// A restore, and a fetch from the cloud, must be the only writer into the target: take the lease
// from whoever has it and wait one renewal, so the former leader has found out and stopped.
async function becomeOnlyWriter() {
const wasLeader = leading;
const { waitMs } = await takeoverLease(leaseCollection(), serverId);
leading = true;
if (!wasLeader) await new Promise(resolve => Meteor.setTimeout(resolve, waitMs));
}
Meteor.methods({
async 'continuousBackup.getSettings'() {
await requireSiteAdmin(this.userId);
@@ -104,12 +162,14 @@ Meteor.methods({
}
}
await ContinuousBackupSettings.upsertAsync('settings', { $set: { ...settings, updatedAt: new Date(), updatedBy: this.userId } });
await backupManager().apply(settings);
return backupManager().status();
// Not apply(): this server may not be the leader. It converges through the lease like every
// other server; the leader follows the change on its next tick.
await evaluateLease();
return { ...backupManager().status(), leading };
},
async 'continuousBackup.status'() {
await requireSiteAdmin(this.userId);
return { ...backupManager().status(), restoring };
return { ...backupManager().status(), restoring, leading };
},
// A lost or empty target filled again from the cloud copy, so its restore
// points can be restored. Files already here are kept.
@@ -121,13 +181,15 @@ Meteor.methods({
const remote = cloudRemote(settings.upload);
if (!remote) throw new Meteor.Error('continuous-backup-no-upload', `The ${settings.upload} storage is not configured`);
restoring = true;
await backupManager().stop();
try {
await becomeOnlyWriter();
await backupManager().stop();
fs.mkdirSync(settings.target, { recursive: true, mode: 0o700 });
return await fetchFromCloud({ target: settings.target, remote, prefix: settings.uploadPrefix });
} finally {
restoring = false;
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
appliedStamp = null;
if (settings.enabled) await evaluateLease();
}
},
async 'continuousBackup.restorePoints'() {
@@ -164,6 +226,7 @@ Meteor.methods({
// The stream pauses while the restore reads its target, and resumes
// from its saved position after it: the restore's writes are changes
// like any other, and are recorded then.
await becomeOnlyWriter();
await backupManager().stop();
try {
if (request.what === 'database') {
@@ -175,7 +238,9 @@ Meteor.methods({
.map(source => [`${source.area}\0${source.key}`, { path: source.path, file: !!source.file }]));
return await restoreFiles({ target, name: request.generation, until: request.until, roots, mode: request.mode, cipher });
} finally {
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
appliedStamp = null;
restoring = false;
if (settings.enabled) await evaluateLease();
}
} catch (error) {
if (error.code === 'continuous-backup-restore-refused' || error.code === 'continuous-backup-key') {
@@ -189,7 +254,7 @@ Meteor.methods({
Meteor.startup(async () => {
try {
const settings = await savedSettings();
if (settings.enabled) await backupManager().apply(settings);
await evaluateLease();
startLeaseLoop();
} catch (error) { reportProblem('startup', error); }
});
+21
View File
@@ -0,0 +1,21 @@
import { Meteor } from 'meteor/meteor';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
const { uploadPolicy } = require('/models/lib/attachmentUploadPolicy');
// #6870: tells a signed-in client whether uploads are blocked site-wide, so a card does not offer a
// "+" button and a drop zone for an upload the server will refuse. The refusal itself is enforced
// on the server whatever the client does (models/attachments.server.js, server/attachmentApi.js);
// this only decides what is shown. Only the boolean is returned.
Meteor.methods({
async attachmentUploadPolicy() {
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
let limitSettings;
try {
limitSettings = (await AttachmentStorageSettings.findOneAsync({}))?.limitSettings;
} catch (error) {
// an unreadable setting leaves uploads as they were, as the server's own check does
limitSettings = undefined;
}
return uploadPolicy(limitSettings);
},
});
+2 -1
View File
@@ -241,7 +241,8 @@ WebApp.handlers.get(
});
} else {
sendJsonResult(res, {
code: 500,
code: 404,
data: { error: 'Checklist not found or does not belong to the specified card' },
});
}
},
+20
View File
@@ -110,11 +110,29 @@ WebApp.handlers.post('/api/boards/:boardId/integrations', async function(req, re
}
});
// An integration that is not on THIS board (a wrong id, or the id of another board's
// integration) answers 404, as GET does (#6862). The write routes used to answer 200 -
// PUT with the requested id, the activities routes with `data: null` - for an update
// that changed nothing, so a client could not tell it from a success. Checked after
// board-admin authorization, so it tells nobody without that right anything.
async function integrationOr404(res, boardId, intId) {
const integration = await ReactiveCache.getIntegration(
{ _id: intId, boardId },
{ fields: { _id: 1 } },
);
if (!integration) {
sendJsonResult(res, { code: 404, data: { error: 'Integration not found' } });
return null;
}
return integration;
}
WebApp.handlers.put('/api/boards/:boardId/integrations/:intId', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
// #3695: how this webhook delivers - { webhook: { text, fields, grouping,
// schedule, ... } }, or null to inherit the board / Admin Panel default
@@ -201,6 +219,7 @@ WebApp.handlers.delete(
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
await Integrations.direct.updateAsync(
{ _id: paramIntId, boardId: paramBoardId },
@@ -228,6 +247,7 @@ WebApp.handlers.post(
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
await Integrations.direct.updateAsync(
{ _id: paramIntId, boardId: paramBoardId },
+4 -4
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '12.90'
version: '12.94'
base: core24
confinement: strict
grade: stable
@@ -703,18 +703,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-12.90-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-12.94-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v12.90/wekan-12.90-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v12.94/wekan-12.94-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v12.90/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v12.94/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
python3 ../releases/check-telemetry.py --bundle bundle
rm "${WEKAN_ZIP}"
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// #6870: attachments off for the whole installation. The switch exists already - Admin Panel /
// Attachments / Transfer limits, "Attachments upload" and "API upload" set to Blocked - and the
// server refuses on every path. What the card lacked was to STOP OFFERING the upload. This pins
// the client side, and the server enforcement it relies on, so the two cannot drift apart.
//
// Run: node tests/attachmentUploadPolicy.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { uploadPolicy } = require('../models/lib/attachmentUploadPolicy');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
test('only a real true blocks uploads', () => {
assert.deepEqual(uploadPolicy({ attachmentsUploadBlocked: true }), { uploadsBlocked: true });
for (const v of [undefined, null, {}, [], 'x', { attachmentsUploadBlocked: false },
{ attachmentsUploadBlocked: 'true' }, { attachmentsUploadBlocked: 1 }, { apiUploadBlocked: true }]) {
assert.deepEqual(uploadPolicy(v), { uploadsBlocked: false }, JSON.stringify(v));
}
});
test('negative: the answer carries only the boolean, nothing about limits or storage', () => {
const answer = uploadPolicy({ attachmentsUploadBlocked: true, attachmentsUploadMaxBytes: 5, apiUploadMaxBytes: 7, secret: 'x' });
assert.deepEqual(Object.keys(answer), ['uploadsBlocked']);
});
test('the method answers signed-in users only, and falls back to "not blocked" on a read error', () => {
const src = read('server/methods/attachmentUploadPolicy.js');
assert.match(src, /async attachmentUploadPolicy\(\) \{\s*if \(!this\.userId\) throw new Meteor\.Error\('error-notAuthorized'\);/);
assert.match(src, /catch \(error\) \{[^}]*limitSettings = undefined;/);
});
test('the card offers the "+" only when uploads are not blocked', () => {
const jade = read('client/components/cards/attachments.jade');
assert.match(jade, /if canAddAttachment\s*\n\s*a\.card-label\.add-label\.add-attachment\.js-add-attachment/);
assert.doesNotMatch(jade, /if canModifyCard\s*\n\s*a\.card-label\.add-label\.add-attachment/, 'the old condition is gone');
const js = read('client/components/cards/attachments.js');
assert.match(js, /canAddAttachment\(\) \{\s*return Utils\.canModifyCard\(\) && !attachmentUploadsBlocked\(\);/);
// and a drop or paste does nothing either
assert.match(js, /if \(attachmentUploadsBlocked\(\)\) return \[\];\s*\n\s*\/\/ Check if board allows attachments/);
});
test('the client asks once a minute at most and shows uploads until the server answers', () => {
const src = read('client/lib/attachmentUploadPolicy.js');
assert.match(src, /new ReactiveVar\(false\)/);
assert.match(src, /Date\.now\(\) - askedAt < 60 \* 1000/);
assert.match(src, /catch\(\(\) => \{ askedAt = 0; \}\)/);
});
test('the server refuses on every upload path whatever the client shows', () => {
const pipeline = read('models/attachments.server.js');
assert.match(pipeline, /uploadsBlocked = settings\?\.limitSettings\?\.attachmentsUploadBlocked === true/);
const methods = read('server/attachmentApi.js');
const routes = read('server/routes/attachmentApi.js');
// api.attachment.upload and api.attachment.copy, the HTTP upload, upload-background and copy
assert.equal((methods.match(/if \(apiUploadBlocked\)/g) || []).length >= 2, true, 'upload and copy methods');
assert.equal((routes.match(/if \(apiUploadBlocked\)/g) || []).length >= 3, true, 'upload, background and copy routes');
});
test('negative: there is no second "disable attachments" setting to disagree with the server', () => {
for (const f of ['models/settings.js', 'server/publications/settings.js', 'client/components/settings/settingBody.jade']) {
assert.doesNotMatch(read(f), /disableAttachments/, f);
}
});
+107
View File
@@ -0,0 +1,107 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const exportFields = require('../models/lib/exportFields');
function fixture({ board, disabled = false, lookupError } = {}) {
let handler;
const calls = [];
class Exporter {
constructor() { calls.push('exporter'); }
async build(res) { calls.push('build'); res.end('workbook'); }
}
const context = {
runOnServer: fn => fn(),
ReactiveCache: { async getBoard(id) {
calls.push(['board', id]);
if (lookupError) throw lookupError;
return board;
} },
Meteor: { settings: { public: {} } },
require(name) {
switch (name) {
case './server/ExporterExcel': return { ExporterExcel: Exporter };
case './server/ExporterExcelBoard': return { ExporterExcelBoard: Exporter };
case '/models/lib/exportFields': return exportFields;
case 'meteor/webapp': return { WebApp: { handlers: { get(url, fn) {
assert.equal(url, '/api/boards/:boardId/exportExcel');
handler = fn;
} } } };
case '/server/apiMiddleware': return { safeRoute: fn => fn };
case '/server/authentication': return { Authentication: { async checkUserId() {
calls.push('authenticate');
throw Object.assign(new Error('Unauthorized'), { statusCode: 403 });
} } };
case '/models/lib/importExportSecurity': return { async refuseDisabledExport(res) {
if (!disabled) return false;
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Export disabled');
return true;
} };
default: throw new Error(`Unexpected module: ${name}`);
}
},
};
const source = fs.readFileSync(path.join(__dirname, '../models/exportExcel.js'), 'utf8');
vm.runInNewContext(source.replace(/^import .*;\r?\n/gm, ''), context);
assert.equal(typeof handler, 'function');
return {
calls,
async get(query = {}) {
const res = {
statusCode: 200,
headers: {},
ends: 0,
writeHead(status, headers) { this.statusCode = status; this.headers = headers; },
end(body) { this.body = body; this.ends += 1; },
};
await handler({ params: { boardId: 'board' }, query }, res);
assert.equal(res.ends, 1, 'respond exactly once');
return res;
},
};
}
for (const query of [{}, { authToken: 'token' }]) {
test(`a missing board returns plain-text 404 ${query.authToken ? 'with a token' : 'anonymously'}`, async () => {
const f = fixture();
const res = await f.get(query);
assert.equal(res.statusCode, 404);
assert.equal(res.headers['Content-Type'], 'text/plain; charset=utf-8');
assert.equal(res.body, 'Board not found');
assert.deepEqual(f.calls, [['board', 'board']]);
});
}
for (const query of [{}, { fields: 'description' }]) {
test(`an existing public board still builds ${query.fields ? 'the table' : 'the card layout'}`, async () => {
const f = fixture({ board: { isPublic: () => true } });
const res = await f.get(query);
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'workbook');
assert.deepEqual(f.calls, [['board', 'board'], 'exporter', 'build']);
});
}
test('an existing private board still requires authentication', async () => {
const f = fixture({ board: { isPublic: () => false } });
const res = await f.get();
assert.equal(res.statusCode, 403);
assert.equal(res.body, 'Unauthorized');
assert.deepEqual(f.calls, [['board', 'board'], 'authenticate']);
});
test('disabled exports are refused before looking up a board', async () => {
const f = fixture({ disabled: true });
assert.equal((await f.get()).statusCode, 403);
assert.deepEqual(f.calls, []);
});
test('a database failure is not misreported as a missing board', async () => {
const error = new Error('database unavailable');
await assert.rejects(() => fixture({ lookupError: error }).get(), error);
});
+1 -1
View File
@@ -19,7 +19,7 @@ const { buildBoardLabel } = require('../models/lib/restLabel.js');
const COLORS = ['green', 'red', 'blue'];
const source = fs.readFileSync(path.join(__dirname, '../models/boards.js'), 'utf8');
const start = source.indexOf(' getLabel(name, color) {');
const getLabel = vm.runInNewContext(`(function ${source.slice(start + 2, source.indexOf('\n },', start) + 4).replace(/^getLabel/, 'getLabel')})`);
const getLabel = vm.runInNewContext(`(function ${source.slice(start + 2, source.indexOf('\n },', start) + 4)})`);
const board = labels => ({ labels, getLabel });
+69
View File
@@ -0,0 +1,69 @@
'use strict';
// The board-open profile (docs/Databases/FerretDB/1/SpeedTest/app): the part that can be
// checked without a browser and a database - the CPU profile of the Meteor server is
// split by who spends it, and the README states what was measured and what was not.
//
// Run: node tests/boardOpenProfile.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { categorise, analyse } = require('../docs/Databases/FerretDB/1/SpeedTest/app/profileAnalysis.cjs');
const DIR = path.join(__dirname, '..', 'docs/Databases/FerretDB/1/SpeedTest/app');
const node = (id, functionName, url = '') => ({ id, callFrame: { functionName, url } });
test('time is split by who spends it', () => {
const cases = [
['(idle)', '', 'idle'],
['(garbage collector)', '', 'garbage collector'],
['(program)', '', 'native (program)'],
['deserializeObject', 'file:///x/npm-mongo/node_modules/mongodb/lib/bson.cjs', 'MongoDB driver (wire, decode, encode)'],
['publish', 'file:///x/programs/server/packages/ddp-server.js', 'Meteor DDP (publish, mergebox, sessions)'],
['replaceTypes', 'file:///x/programs/server/packages/mongo.js', 'Meteor mongo (observe, polling, diff)'],
['stringify', 'file:///x/programs/server/packages/ejson.js', 'EJSON'],
['CollectionDocument', 'file:///x/_build/main-dev/server-rspack.cjs', 'WeKan code'],
['writeBuffer', 'node:internal/stream_base_commons', 'node internals'],
['something', 'file:///x/programs/server/packages/random.js', 'other packages'],
];
for (const [fn, url, want] of cases) assert.equal(categorise(node(1, fn, url)), want, `${fn} ${url}`);
// negative: a dependency under node_modules, and a Meteor package whose path has /server/ in it,
// are never "WeKan code", even when the path mentions wekan
assert.notEqual(categorise(node(1, 'f', 'file:///x/wekan/node_modules/lodash/lodash.js')), 'WeKan code');
assert.equal(categorise(node(1, 'f', 'file:///x/wekan/.meteor/local/build/programs/server/packages/check.js')), 'other packages');
});
test('analyse adds up the samples, leaves the idle time out of "busy", and orders by cost', () => {
const nodes = [node(1, '(idle)'), node(2, 'deserializeObject', 'file:///x/mongodb/lib/bson.cjs'),
node(3, 'CollectionDocument', 'file:///x/_build/server.cjs'), node(4, '(garbage collector)')];
// timeDeltas are in microseconds; sample i is charged the delta after it
const profile = { nodes, samples: [1, 2, 2, 3, 4, 1], timeDeltas: [0, 4000, 6000, 3000, 2000, 5000, 0] };
const a = analyse(profile);
assert.equal(Math.round(a.totalMs), 20);
assert.equal(Math.round(a.busyMs), 16, '4 of the 20 ms are idle');
assert.deepEqual(a.categories.map(([c]) => c), ['MongoDB driver (wire, decode, encode)', 'garbage collector', 'WeKan code']);
assert.deepEqual(a.categories.map(([, ms]) => Math.round(ms)), [9, 5, 2]);
assert.ok(a.categories.every(([, ms]) => ms > 0));
assert.equal(a.topFunctions[0][0].split(' ')[0], 'deserializeObject');
// negative: an empty profile is nothing, not NaN
const empty = analyse({ nodes: [], samples: [], timeDeltas: [] });
assert.equal(empty.busyMs, 0);
assert.deepEqual(empty.categories, []);
});
test('the script parses, never touches a database outside its own, and the README says what it measured', () => {
const { execFileSync } = require('node:child_process');
execFileSync(process.execPath, ['--check', path.join(DIR, 'board-open.cjs')]);
const script = fs.readFileSync(path.join(DIR, 'board-open.cjs'), 'utf8');
// it seeds a user and a board of its own, in the database named by WEKAN_MONGO_URL
assert.match(script, /db\.seedUser\(\)/);
assert.doesNotMatch(script, /dropDatabase|deleteMany\(\{\}\)|drop\(\)/);
const md = fs.readFileSync(path.join(DIR, 'README.md'), 'utf8');
for (const needle of ['development build', 'same ~210 KB', 'run 1 / run 2', '400-600 ms', 'within the noise', 'not identified', 'does not separate']) {
assert.ok(md.includes(needle), needle);
}
// the root README points to it
assert.ok(fs.readFileSync(path.join(DIR, '..', 'README.md'), 'utf8').includes('[app/](app/)'));
});
+53 -19
View File
@@ -41,8 +41,9 @@ console.log('bundleNpmSecurityBumps:');
test('the manifest lists the packages the scan found, at fixed versions', () => {
const expected = {
'nodemailer': '8.0', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16', 'body-parser': '1.20',
'nodemailer': '10.3', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16.1', 'body-parser': '1.20.9', 'body-parser@2': '2.4',
'uuid': '11.1.1', 'postcss-selector-parser': '6.1.5',
'cookie': '0.7', 'on-headers': '1.1', 'tmp': '0.2', 'diff': '3.5',
'@babel/runtime': '7.', 'underscore': '1.13.8',
// Artifact Hub's Trivy report of the v12.38 image: same-major fixes.
@@ -57,25 +58,58 @@ test('the manifest lists the packages the scan found, at fixed versions', () =>
}
});
test('uuid is NOT bumped, and says why (negative)', () => {
// The one that was tried and reverted. If somebody adds it back, the bundle
// stops booting - so the reason lives where they would look.
assert.ok(!('uuid' in manifest.minimums), 'uuid must not be in minimums');
const reason = (manifest.notFixable.uuid || []).join(' ');
assert.ok(/Cannot find module/.test(reason) && /dist\/cjs\/index\.js/.test(reason),
'and notFixable carries the boot error it caused');
test('uuid is bumped to 11.1.1 and the entry point the bundle recorded is written back', () => {
// uuid 11 moved its entry from dist/index.js to dist/cjs/index.js, and a bundle
// that recorded dist/index.js died on boot ("Cannot find module
// .../uuid/dist/index.js"). The fix is a shim at the old path, not a refusal.
assert.strictEqual(manifest.minimums.uuid, '11.1.1');
const shim = (manifest.shims || {}).uuid || {};
assert.strictEqual(shim['dist/index.js'], "export * from './esm/index.js';\n",
'dist/index.js re-exports the ESM build (uuid is "type": "module"; a CJS shim would be parsed as ESM)');
assert.ok(/manifest\.shims/.test(bumper) && /writeFileSync\(file, body\)/.test(bumper),
'and the bumper writes it after every replacement');
// negative: nothing is described as unfixable that was fixed
for (const name of ['uuid', 'nodemailer']) {
assert.ok(!(name in manifest.notFixable), `${name} must not stay in notFixable`);
}
});
test('a fix that needs a new major is recorded in notFixable, never in minimums (negative)', () => {
for (const name of ['nodemailer', 'postcss-selector-parser', 'uuid']) {
const reason = (manifest.notFixable[name] || []).join(' ');
assert.ok(/major/.test(reason), `${name} says the fix is a new major`);
}
// nodemailer stays at the newest 8.x; the 9.x/10.x fixes are named, not taken.
assert.ok(manifest.minimums.nodemailer.startsWith('8.'));
assert.ok(!('postcss-selector-parser' in manifest.minimums));
// lodash.template has had a fixed release (4.18.x) since 2026-04; it is no
// longer described as unfixable.
test('body-parser 1.x and 2.x are each raised inside their own major', () => {
assert.strictEqual(manifest.minimums['body-parser'], '1.20.9');
assert.strictEqual(manifest.minimums['body-parser@2'], '2.4.0');
const fs2 = require('fs'), os = require('os'), cp = require('child_process');
const bundle = fs2.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'bump-test-'));
const put = (dir, version) => {
const d = path.join(bundle, 'programs/server/npm/node_modules', dir);
fs2.mkdirSync(d, { recursive: true });
fs2.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version }));
};
put('meteor/a/node_modules/body-parser', '1.20.6');
put('meteor/b/node_modules/body-parser', '2.3.0');
put('meteor/c/node_modules/body-parser', '2.4.0');
put('meteor/d/node_modules/body-parser', '1.20.9');
put('meteor/e/node_modules/uuid', '8.3.2');
put('meteor/f/node_modules/uuid', '14.0.2');
put('meteor/g/node_modules/nodemailer', '8.0.11');
const out = cp.execFileSync(process.execPath,
[path.join(repoRoot, 'releases/bump-bundle-npm-deps.mjs'), bundle, '--dry-run'], { encoding: 'utf8' });
fs2.rmSync(bundle, { recursive: true, force: true });
const lines = out.split('\n').filter(l => /->/.test(l));
const has = re => lines.some(l => re.test(l));
assert.ok(has(/body-parser 1\.20\.6 -> 1\.20\.9/), '1.x below 1.20.9 is raised to 1.20.9');
assert.ok(has(/body-parser 2\.3\.0 -> 2\.4\.0/), '2.3.0 is raised to 2.4.0');
// negative: neither crosses into the other major, and fixed copies are left alone
assert.ok(!has(/body-parser 1\.\d+\.\d+ -> 2\./) && !has(/body-parser 2\.\d+\.\d+ -> 1\./));
assert.ok(!has(/body-parser 2\.4\.0 ->/) && !has(/body-parser 1\.20\.9 ->/));
assert.ok(has(/uuid 8\.3\.2 -> 11\.1\.1/) && !has(/uuid 14\./), 'uuid 14 is above the minimum');
assert.ok(has(/nodemailer 8\.0\.11 -> 10\.3\.1/));
});
test('a fix that needs a new major its dependents cannot take is recorded in notFixable (negative)', () => {
const reason = (manifest.notFixable['postcss-selector-parser'] || []).join(' ');
assert.ok(/major/.test(reason) && /CVE-2026-104844/.test(reason));
assert.ok(manifest.minimums['postcss-selector-parser'] === '6.1.5',
'but the same-major fix that exists is taken');
assert.ok(!('lodash.template' in manifest.notFixable));
for (const [name, version] of Object.entries(manifest.minimums)) {
assert.ok(/^\d+\.\d+\.\d+$/.test(version), `${name} is pinned to an exact version`);
+28
View File
@@ -0,0 +1,28 @@
'use strict';
// A phone in desktop mode: the opened card starts below the (multi-row) header bar and a
// dragged place is kept. Run: node tests/cardBelowHeaderOnPhone.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const css = fs.readFileSync(path.join(__dirname, '..', 'client/components/cards/cardDetails.css'), 'utf8');
const block = /@media screen and \(max-width: 800px\) \{\n body\.desktop-mode \.card-details:not\(\.card-details-popup\):not\(\[style\*="top"\]\),[^{]*\{([^}]*)\}/.exec(css);
test('below 801px the desktop-mode card starts at the measured header height', () => {
assert.ok(block, 'the phone rule exists');
assert.match(block[1], /top: calc\(var\(--wekan-header-height, 0px\) \+ 8px\) !important;/);
});
test('negative: a card with an inline top (dragged) never matches it, so the drag sticks', () => {
const selectors = css.slice(css.indexOf(block[0]), css.indexOf(block[1])).replace(/\/\*.*?\*\//g, '').split(',');
assert.ok(selectors.every(s => /:not\(\[style\*="top"\]\)|card-details-maximized/.test(s)));
// the variable is published by Utils, so the rule has a source
assert.match(fs.readFileSync(path.join(__dirname, '..', 'client/lib/utils.js'), 'utf8'), /--wekan-header-height/);
});
test('a card opened as a popup starts below the header too, not at the top of the window', () => {
assert.ok(block[0].includes('.card-details.card-details-popup:not([style*="top"])'));
});
+88 -8
View File
@@ -248,19 +248,99 @@ test('the bottom-right handle can make the opened card wider or narrower', () =>
test('a window the user dragged keeps its place, and is only kept on screen', () => {
const js = read('client/components/cards/cardDetails.js');
// Both drag handles — the header handle and the title — must mark it.
const handles = [...js.matchAll(/'mousedown \.js-card-(?:title-)?drag-handle'\(event\) \{/g)];
// Both drag handles - the header handle and the title - must mark it. They are
// Pointer Events now (a finger sends no mouse events while it moves, so the mouse-only
// handlers left the card fixed on an iPad); both start the same drag, beginCardWindowDrag,
// and THAT marks the window as user-placed once it really moves.
const handles = [...js.matchAll(/'pointerdown \.js-card-(?:title-)?drag-handle'\(event\) \{/g)];
assert.strictEqual(handles.length, 2, 'both drag handles exist');
for (const handle of handles) {
// The whole handler, not a byte window: each of them starts with guards -
// the title one steps aside for links and for the half of the title that
// edits - and a fixed slice silently stops covering the thing being checked
// the first time one of those grows.
const end = js.indexOf('\n },', handle.index);
const body = js.slice(handle.index, end === -1 ? js.length : end);
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(body),
'dragging must mark the window as user-placed');
assert.ok(/beginCardWindowDrag\(event, /.test(body), 'each starts the shared drag');
}
const drag = js.slice(js.indexOf('function beginCardWindowDrag('), js.indexOf('Template.cardDetails.events({'));
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(drag),
'dragging must mark the window as user-placed');
const at = js.indexOf('function anchorCardDetailsX(');
assert.notStrictEqual(at, -1, 'the DOM half must exist');
const body = js.slice(at, js.indexOf('\n}', at));
for (const prop of ['top', 'bottom', 'height']) {
assert.ok(!new RegExp(`el\\.style\\.${prop}\\s*=`).test(body),
`it must not write style.${prop} — Y is already correct (#6465)`);
}
for (const prop of ['left', 'right', 'width']) {
assert.ok(new RegExp(`el\\.style\\.${prop}\\s*=`).test(body), `it writes style.${prop}`);
}
});
// ── wiring ──────────────────────────────────────────────────────────────────
test('it runs when a card is opened, and again when the viewport changes', () => {
const js = read('client/components/cards/cardDetails.js');
assert.ok(/Tracker\.afterFlush\(\(\) => \{\s*const element = \$cardDetails\.get\(0\);\s*try \{\s*anchorCardDetailsX\(element\)/.test(js),
'placed after the flush that renders it, so the measured width is the real one');
assert.ok(/addEventListener\('resize'/.test(js) && /anchorAllCardDetailsX\(\)/.test(js),
'and re-placed on resize — a viewport that shrank is the other way to lose it');
assert.ok(/requestAnimationFrame/.test(js),
'coalesced into a frame: a window drag-resize fires resize continuously');
});
test('desktop cards stay unpainted until initial placement, including fallback paths', () => {
const jade = read('client/components/cards/cardDetails.jade');
const css = read('client/components/cards/cardDetails.css');
const js = read('client/components/cards/cardDetails.js');
// Drawn from a reactive helper, not as a static class: Blaze re-adds a
// tag's static classes whenever its class attribute changes, so a static
// pending class came back after Maximize / Minimize and hid the card.
assert.match(jade, /section\.card-details\.js-card-details[^\n]*\(\s*class='\{\{#unless positionSettled\}\}card-details-position-pending\{\{\/unless\}\}'/);
assert.doesNotMatch(jade, /section\.card-details[^\n(]*\.card-details-position-pending/,
'negative: never a static class of the section');
assert.match(js, /positionSettled = new ReactiveVar\(false\)/);
assert.match(js, /element\.classList\.remove\('card-details-position-pending'\);[\s\S]*?tpl\.positionSettled\.set\(true\)/);
assert.match(css, /body\.desktop-mode \.card-details\.card-details-position-pending:not\(\.card-details-popup\):not\(\.card-details-maximized\) \{\s*visibility: hidden;/);
assert.match(js, /anchorCardDetailsX\(element\);\s*\} finally \{[\s\S]*?element\.classList\.remove\('card-details-position-pending'\)/);
});
test('the bottom-right handle can make the opened card wider or narrower', () => {
const css = read('client/components/cards/cardDetails.css');
const desktopAt = css.indexOf(
'body.desktop-mode .card-details:not(.card-details-popup) {',
);
const desktopRule = css.slice(desktopAt, css.indexOf('\n}', desktopAt));
assert.match(desktopRule, /max-width: calc\(100vw - 16px\)/,
'the desktop card can grow to the viewport edge');
assert.doesNotMatch(desktopRule, /max-width: 520px/,
'the initial width must not also be the resize ceiling');
const defaultAt = css.indexOf(
'body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"])',
);
const defaultRule = css.slice(defaultAt, css.indexOf('\n}', defaultAt));
assert.match(defaultRule, /width: min\(520px, 90vw\)/,
'the card still opens at its established compact width');
assert.match(css, /@media screen and \(min-width: 801px\)[\s\S]*?\.card-details \{[\s\S]*?resize: both;/,
'desktop users retain the two-way native resize handle');
assert.match(css, /body\.mobile-mode \.card-details \{[\s\S]*?resize: none !important;/,
'mobile remains full-screen rather than resizable');
});
test('a window the user dragged keeps its place, and is only kept on screen', () => {
const js = read('client/components/cards/cardDetails.js');
// Both drag handles - the header handle and the title - must mark it. They are
// Pointer Events now (a finger sends no mouse events while it moves, so the mouse-only
// handlers left the card fixed on an iPad); both start the same drag, beginCardWindowDrag,
// and THAT marks the window as user-placed once it really moves.
const handles = [...js.matchAll(/'pointerdown \.js-card-(?:title-)?drag-handle'\(event\) \{/g)];
assert.strictEqual(handles.length, 2, 'both drag handles exist');
for (const handle of handles) {
const end = js.indexOf('\n },', handle.index);
const body = js.slice(handle.index, end === -1 ? js.length : end);
assert.ok(/beginCardWindowDrag\(event, /.test(body), 'each starts the shared drag');
}
const drag = js.slice(js.indexOf('function beginCardWindowDrag('), js.indexOf('Template.cardDetails.events({'));
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(drag),
'dragging must mark the window as user-placed');
const at = js.indexOf('function anchorCardDetailsX(');
const body = js.slice(at, js.indexOf('\n}', at));
assert.ok(/USER_MOVED_ATTR\) === '1'/.test(body), 'and the placement must honour it');
+41
View File
@@ -0,0 +1,41 @@
'use strict';
// The opened card covers the header bar on an iPad. iOS WebKit (iPad Safari and Chrome)
// makes every scrolling element a stacking context, so the card's z-index is compared
// only inside #content, and the header bar (#header-quick-access, z-index 1000, a flex
// item of body) was painted over the card's top row. While a card is open the header
// drops to the level of the content. tests/playwright/specs/card-over-header-ios-stacking
// .e2e.js simulates the trap and shows it in a browser.
//
// Run: node tests/cardOverHeaderStacking.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const header = read('client/components/main/header.css');
test('while a card is open the header bar is at the level of the content', () => {
assert.match(header, /body:has\(\.card-details\) #header-quick-access \{\n z-index: 0;\n\}/);
// negative: it is conditional - the header keeps 1000 when no card is open
assert.match(header, /\n#header-quick-access \{[^}]*z-index: 1000;/);
assert.doesNotMatch(header.replace(/body:has\(\.card-details\) #header-quick-access \{\n z-index: 0;\n\}/, ''), /#header-quick-access \{[^}]*z-index: 0;/);
});
test('it names the header that exists: #header-quick-access, not the old #header', () => {
const rule = /body:has\(\.card-details\) (#[\w-]+)/.exec(header);
assert.equal(rule[1], '#header-quick-access');
assert.ok(read('client/components/main/header.jade').includes('#header-quick-access'), 'the template draws it');
// the card's own level is above the header's normal one, so a browser without the trap needs nothing
const card = read('client/components/cards/cardDetails.css');
assert.match(card, /body\.desktop-mode \.card-details:not\(\.card-details-popup\) \{[^}]*z-index: 2001;/);
});
test('the content is the later sibling that paints over the header at the same level', () => {
// #content comes after the header in the page and is positioned, so at z-index 0 against 0
// it is painted last, which is what puts the card over the header's second row as well.
const layouts = read('client/components/main/layouts.css');
assert.match(layouts, /\n#content \{\n position: relative;\n flex: 1;/);
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// A card opened as a popup docks at the top of the window and covers the header bars,
// like every other opened card. The ≥801px rule gave every .card-details top: 97px,
// the room the header takes, and the desktop-mode rules that dock a card (top: 8px,
// z-index 2001) skip a popup (:not(.card-details-popup)), so a popup card opened
// below the header - and, collapsed, floated over the swimlane as a bar.
// tests/playwright/specs/card-popup-over-header.e2e.js shows it in a browser.
//
// Run: node tests/cardPopupOverHeader.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const css = fs.readFileSync(path.join(__dirname, '..', 'client/components/cards/cardDetails.css'), 'utf8');
// The body of the first @media block with this query, by brace matching.
function mediaBlock(query) {
const start = css.indexOf(`@media ${query} {`);
assert.ok(start >= 0, `${query} block`);
let depth = 0;
for (let i = css.indexOf('{', start); i < css.length; i += 1) {
if (css[i] === '{') depth += 1;
if (css[i] === '}') { depth -= 1; if (depth === 0) return css.slice(start, i + 1); }
}
throw new Error('unbalanced');
}
test('a popup card docks at the top, over the header, in the wide layout', () => {
const wide = mediaBlock('screen and (min-width: 801px)');
const rule = /\n \.card-details\.card-details-popup \{([^}]*)\}/.exec(wide);
assert.ok(rule, 'a rule for the popup card in the ≥801px block');
assert.match(rule[1], /top:\s*8px;/);
assert.match(rule[1], /z-index:\s*2001;/, 'above the header bars (1000)');
// negative: it is no longer left at the header's height
assert.doesNotMatch(rule[1], /97px/);
// the base rule still has the 97px, for a card nothing else docks - so the popup rule is what changes it
assert.match(wide, /\.card-details \{\s+top: 97px;/);
});
test('the popup rule comes after the base rule, so it wins by order as well as by specificity', () => {
const wide = mediaBlock('screen and (min-width: 801px)');
assert.ok(wide.indexOf('.card-details.card-details-popup {') > wide.indexOf('.card-details {\n top: 97px;'));
});
test('mobile mode keeps its own full-window rule, and the popup wrapper still covers the header', () => {
// body.mobile-mode .card-details is top: 0 !important with z-index 1100 !important (#6419).
assert.match(css, /body\.mobile-mode \.card-details \{[^}]*z-index: 1100 !important;/);
assert.match(css, /\.pop-over\[data-popup="cardDetailsPopup"\] \{[^}]*z-index: 100000 !important;/);
// negative: the new rule is not written with !important, so it cannot beat the mobile ones
const wide = mediaBlock('screen and (min-width: 801px)');
const rule = /\n \.card-details\.card-details-popup \{([^}]*)\}/.exec(wide)[1];
assert.doesNotMatch(rule, /!important/);
});
+115
View File
@@ -0,0 +1,115 @@
'use strict';
// #6861, item 3: with several WeKan servers on one database exactly ONE streams the continuous
// backup, and another takes over when it stops. Run: node tests/continuousBackupLease.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease } = require('../models/lib/backupLease');
// A collection with the atomic semantics the lease relies on: every call is ONE step, and an insert
// of an existing _id is a duplicate key. (Each method runs to completion before the next starts, as
// a database's single-document operations do.)
function fakeCollection() {
const docs = new Map();
const matches = (doc, filter, now) => Object.entries(filter).every(([key, want]) => {
if (key === '$or') return want.some(branch => matches(doc, branch, now));
const have = doc[key];
if (want && typeof want === 'object' && !(want instanceof Date)) {
if ('$lte' in want) return have instanceof Date && have <= want.$lte;
return false;
}
if (want === null) return have === null || have === undefined;
return have === want;
});
return {
docs,
async insertOne(doc) {
await Promise.resolve();
if (docs.has(doc._id)) throw Object.assign(new Error('E11000 duplicate key error'), { code: 11000 });
docs.set(doc._id, { ...doc });
return { insertedId: doc._id };
},
async updateOne(filter, update) {
await Promise.resolve();
const doc = docs.get(filter._id);
if (!doc || !matches(doc, filter)) return { matchedCount: 0, modifiedCount: 0 };
Object.assign(doc, update.$set);
return { matchedCount: 1, modifiedCount: 1 };
},
};
}
const at = ms => new Date(1700000000000 + ms);
test('24 servers acquiring at once: exactly one leads', async () => {
const c = fakeCollection();
const results = await Promise.all(Array.from({ length: 24 }, (_, i) => acquireLease(c, `server-${i}`, at(0))));
assert.equal(results.filter(Boolean).length, 1);
assert.equal(c.docs.size, 1, 'one lease document');
});
test('the leader renews, and the others stay out while the lease is alive', async () => {
const c = fakeCollection();
assert.equal(await acquireLease(c, 'a', at(0)), true);
assert.equal(await acquireLease(c, 'b', at(RENEW_MS)), false, 'b cannot take a live lease');
assert.equal(await acquireLease(c, 'a', at(RENEW_MS)), true, 'a renews');
assert.equal(c.docs.get(LEASE_ID).expiresAt.getTime(), at(RENEW_MS).getTime() + TTL_MS, 'and the expiry moves');
assert.equal(await acquireLease(c, 'b', at(2 * RENEW_MS)), false, 'still a\'s after the renewal');
});
test('failover: a leader that stops renewing is replaced once its lease has expired', async () => {
const c = fakeCollection();
await acquireLease(c, 'a', at(0));
assert.equal(await acquireLease(c, 'b', at(TTL_MS - 1)), false, 'one millisecond before it lapses');
assert.equal(await acquireLease(c, 'b', at(TTL_MS)), true, 'when it lapses b leads');
assert.equal(await acquireLease(c, 'a', at(TTL_MS + 1)), false, 'and the old leader finds it lost it');
});
test('release lets the next server lead at once; only the holder can release', async () => {
const c = fakeCollection();
await acquireLease(c, 'a', at(0));
await releaseLease(c, 'b');
assert.equal(await acquireLease(c, 'c', at(1)), false, 'b was not the holder, so nothing was released');
await releaseLease(c, 'a');
assert.equal(await acquireLease(c, 'c', at(1)), true, 'a released');
});
test('takeover: a restore becomes the only writer, and the former leader loses the lease', async () => {
const c = fakeCollection();
await acquireLease(c, 'leader', at(0));
const { waitMs } = await takeoverLease(c, 'restorer', at(5));
assert.equal(waitMs, RENEW_MS, 'it waits one renewal so the former leader has stopped');
assert.equal(await acquireLease(c, 'leader', at(RENEW_MS)), false, 'the former leader cannot renew');
assert.equal(await acquireLease(c, 'restorer', at(RENEW_MS)), true);
assert.equal((await takeoverLease(fakeCollection(), 'first', at(0))).waitMs, 0, 'with nobody to wait for there is no wait');
});
test('other database errors are not swallowed (negative)', async () => {
const broken = { async insertOne() { throw new Error('connection lost'); }, async updateOne() { return {}; } };
await assert.rejects(() => acquireLease(broken, 'a', at(0)), /connection lost/);
await assert.rejects(() => takeoverLease(broken, 'a', at(0)), /connection lost/);
});
test('negative: the lease never upserts (an unmatched upsert must insert, and what then happens is the database\'s)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'models/lib/backupLease.js'), 'utf8');
assert.doesNotMatch(src.replace(/\/\/.*$/gm, ''), /upsert/);
});
test('continuousBackup.js runs the stream through the lease, on every path', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server/continuousBackup.js'), 'utf8');
// the only direct apply() is inside the leader loop, after acquireLease said this server leads
const applies = src.split('\n').filter(l => /backupManager\(\)\.apply\(/.test(l));
assert.equal(applies.length, 1, 'one apply(), in evaluateLease');
assert.match(src, /leading = await acquireLease\(leaseCollection\(\), serverId\);\s*\n\s*if \(!leading\) \{[^}]*backupManager\(\)\.stop\(\)/);
assert.match(src, /async function evaluateLease\(\) \{\s*\n\s*if \(restoring\) return;/);
// startup and saveSettings converge through the loop; restore and fetchFromCloud take the lease first
assert.match(src, /Meteor\.startup\(async \(\) => \{\s*try \{\s*await evaluateLease\(\);\s*startLeaseLoop\(\);/);
assert.match(src, /await evaluateLease\(\);\s*\n\s*return \{ \.\.\.backupManager\(\)\.status\(\), leading \};/);
assert.equal((src.match(/await becomeOnlyWriter\(\);/g) || []).length, 2, 'restore and fetchFromCloud');
assert.match(src, /const wasLeader = leading;[\s\S]*if \(!wasLeader\) await new Promise/);
// a settings change another server saved is followed by the leader
assert.match(src, /stamp !== appliedStamp/);
});
+87
View File
@@ -148,4 +148,91 @@ test('Due Cards reads only what it uses, and keeps assigned-only boards narrowed
assert.match(pub, /\$and: \[boardScope\],/);
});
// A run whose slowest parameter is set apart from its average, and which says
// the scale it was measured at.
function runWith(label, engine, average, slowest, scale = 0.05) {
const r = fakeRun(label, engine, average);
for (const q of QUERIES) r.queries[q.id].slowest = slowest;
r.scale = scale;
return r;
}
test('Results.md opens with an At a glance table: rank, total and how many times slower than the fastest', () => {
const runs = [runWith('mysql', 'FerretDB + MySQL', 5, 10), runWith('mongodb8', 'MongoDB 8.0.32', 1, 2), runWith('sqlite', 'FerretDB + SQLite', 3, 30)];
const md = render(runs, compare(runs, 'mongodb8'), 'mongodb8', '.');
const glance = md.slice(md.indexOf('## At a glance'), md.indexOf('## Speed'));
assert.ok(md.indexOf('## At a glance') < md.indexOf('## Speed'), 'the summary comes before the long tables');
const rows = glance.split('\n').filter(l => /^\| \d+ \|/.test(l)).map(l => l.split('|').map(c => c.trim()));
assert.deepStrictEqual(rows.map(r => r[1]), ['1', '2', '3']);
assert.deepStrictEqual(rows.map(r => r[2]), ['MongoDB 8.0.32', 'FerretDB + SQLite', 'FerretDB + MySQL'], 'fastest first');
// MongoDB is 1.0x; SQLite's total is 3x the fastest's, MySQL's 5x.
assert.deepStrictEqual(rows.map(r => r[4]), ['1.0x', '3.0x', '5.0x']);
// The slowest-user column is relative to the fastest slowest-user total (2).
assert.deepStrictEqual(rows.map(r => r[6]), ['1.0x', '15x', '5.0x']);
});
test('every table is ordered by its OWN numbers: the slowest-parameter table can differ from the average one', () => {
// A is fastest on average, B has the better slowest user.
const runs = [runWith('a', 'Database A', 1, 50), runWith('b', 'Database B', 2, 5)];
const md = render(runs, compare(runs, 'a'), 'a', '.');
const average = md.slice(md.indexOf('## Speed'), md.indexOf('### The slowest parameter'));
const slowest = md.slice(md.indexOf('### The slowest parameter'), md.indexOf('## Loading the data'));
assert.ok(average.indexOf('| Database A |') < average.indexOf('| Database B |'), 'A first on average');
assert.ok(slowest.indexOf('| Database B |') < slowest.indexOf('| Database A |'), 'B first for the slowest user');
// negative: the old "same row order" claim is gone.
assert.ok(!md.includes('Same row order'));
});
test('the header tells the real data size and the real operating system, not a fixed text', () => {
const small = render([runWith('a', 'A', 1, 2, 0.05)], {}, 'a', '.');
assert.ok(small.includes('The data is 1/20 of a production WeKan'), 'scale 0.05');
assert.ok(render([runWith('a', 'A', 1, 2, 2)], {}, 'a', '.').includes('The data is twice a production WeKan'), 'scale 2');
assert.ok(render([runWith('a', 'A', 1, 2, 1)], {}, 'a', '.').includes('as big as a production WeKan'), 'scale 1');
// negative: a small run must not claim to be twice production, and the machine
// line is the host's, not "Linux" on a Mac.
assert.ok(!small.includes('twice a production WeKan'));
assert.ok(small.includes(`${require('os').type()} ${require('os').release()}`));
});
test('the loading table is ordered by the whole load time, fastest first', () => {
const slowLoad = runWith('slow', 'Slow loader', 1, 2);
const fastLoad = runWith('fast', 'Fast loader', 9, 18);
for (const [run, ms] of [[slowLoad, 5000], [fastLoad, 100]]) {
run.load = Object.fromEntries(['users', 'boards', 'lists', 'swimlanes', 'cards'].map(c => [c, { ms }]));
run.indexTotalMs = ms;
}
const md = render([slowLoad, fastLoad], {}, 'slow', '.');
const load = md.slice(md.indexOf('## Loading the data'), md.indexOf('## Same data'));
assert.ok(load.indexOf('| Fast loader |') < load.indexOf('| Slow loader |'));
});
test('pgrust and TiDB are speed test rows, and opt-in in the conformance run (negative)', () => {
const sh = read('releases/db-conformance.sh');
assert.match(sh, /"pgrust\|image:malisper\/pgrust:v0\.4-beta\|pgrust\|5432\|postgresql"/);
assert.match(sh, /"tidb\|image:pingcap\/tidb:latest\|tidb\|4000\|mysql"/);
assert.match(sh, /"dolt\|image:dolthub\/dolt-sql-server:latest\|dolt\|3306\|mysql"/);
assert.match(sh, /\[ "\$name" = "dolt" \] && \[ "\$SUITE" != speedtest \] && \[ "\${WEKAN_CONFORMANCE_DOLT:-0}" != "1" \]/);
// Not in a plain conformance run: only the speed test, or the variable.
assert.match(sh, /\[ "\$name" = "pgrust" \] && \[ "\$SUITE" != speedtest \] && \[ "\$\{WEKAN_CONFORMANCE_PGRUST:-0\}" != "1" \]/);
assert.match(sh, /\[ "\$name" = "tidb" \] && \[ "\$SUITE" != speedtest \] && \[ "\$\{WEKAN_CONFORMANCE_TIDB:-0\}" != "1" \]/);
// TiDB has no MYSQL_DATABASE variable: the database is created once it answers.
assert.match(sh, /CREATE DATABASE IF NOT EXISTS ferretdb/);
// The two FerretDB changes they needed are in the FerretDB source when present.
const registry = path.join(ROOT, '.tools/FerretDB/internal/backends/mysql/metadata/registry.go');
if (fs.existsSync(registry)) assert.ok(!/ADD COLUMN %s VARCHAR\(255\) GENERATED ALWAYS AS \(\(%s\)\) STORED/.test(fs.readFileSync(registry, 'utf8')), 'TiDB refuses STORED generated columns');
});
test('the README records the databases that were tried and why the others cannot be', () => {
const md = read('docs/Databases/FerretDB/1/SpeedTest/README.md');
const section = md.slice(md.indexOf('## Other databases tried'), md.indexOf('## FerretDB 2'));
for (const name of ['pgrust', 'TiDB', 'Dolt', 'Noria', 'YugabyteDB', 'CockroachDB']) assert.ok(section.includes(name), name);
assert.match(section, /Dolt\]\(https:\/\/github\.com\/dolthub\/dolt\) 2\.6\.2[\s\S]{0,200}no new handler/);
assert.match(section, /INDEX on column of type 'JSONB' not yet supported/);
assert.match(section, /no `JSON` type/);
assert.match(section, /not open source/);
// The ones that need a handler of their own, with the error that shows it.
assert.match(section, /sql: expected 0 arguments, got 2/);
assert.match(section, /Data type of first column cannot be JSON/);
});
console.log(`\n${passed} passed`);
+85
View File
@@ -0,0 +1,85 @@
'use strict';
// docs/Databases/FerretDB/1/SpeedTest/embedded: which Go database could be linked
// into FerretDB beside its SQLite, and is any faster. The benchmark is Go and takes
// minutes; this checks what does not need Go: the data export is deterministic and
// has the parameters the benchmark reads, every engine the benchmark runs is in the
// README with its numbers, and the README keeps its conclusion honest.
//
// Run: node tests/embeddedEnginesBench.test.cjs
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const DIR = path.join(__dirname, '..', 'docs/Databases/FerretDB/1/SpeedTest/embedded');
const read = f => fs.readFileSync(path.join(DIR, f), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
console.log('embeddedEnginesBench:');
function exportTo(scale) {
const out = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'embedded-'));
execFileSync(process.execPath, [path.join(DIR, 'export.cjs'), String(scale), out], { stdio: 'pipe' });
return out;
}
test('the export is deterministic and has the cards and the 15 parameter boards the benchmark reads', () => {
const a = exportTo(0.01);
const b = exportTo(0.01);
try {
assert.strictEqual(fs.readFileSync(path.join(a, 'cards.jsonl'), 'utf8'), fs.readFileSync(path.join(b, 'cards.jsonl'), 'utf8'));
const p = JSON.parse(fs.readFileSync(path.join(a, 'params.json'), 'utf8'));
assert.strictEqual(p.boards.length, 15, '3 biggest boards and 12 ordinary ones');
assert.strictEqual(new Set(p.boards).size, 15, 'no board twice');
const lines = fs.readFileSync(path.join(a, 'cards.jsonl'), 'utf8').trim().split('\n');
assert.strictEqual(lines.length, p.cards);
const first = JSON.parse(lines[0]);
for (const f of ['_id', 'boardId', 'listId', 'archived']) assert.ok(f in first, f);
// negative: every parameter board has cards, or its question would be empty.
const boards = new Set(lines.map(l => JSON.parse(l).boardId));
for (const board of p.boards) assert.ok(boards.has(board), board);
} finally {
fs.rmSync(a, { recursive: true, force: true });
fs.rmSync(b, { recursive: true, force: true });
}
});
test('every engine the benchmark runs is in the README, with its numbers at both sizes', () => {
const engines = read('engines.go');
const names = [...(engines.match(/openSQLite\("([^"]+)"/g) || []), ...(engines.match(/openBolt\("([^"]+)"/g) || [])].map(m => m.replace(/^[^"]*"|"$/g, ''));
assert.ok(names.length >= 5, 'SQLite three ways and bbolt twice');
const md = read('README.md');
for (const n of names) assert.ok(md.split(n).length - 1 >= 2, `${n}: in both tables`);
for (const n of ['Pebble', 'Badger', 'BuntDB', 'Nutsdb', 'Clover']) assert.ok(md.split(n).length - 1 >= 3, n);
assert.match(md, /15,377 cards \(scale 0\.05\)/);
assert.match(md, /153,768 cards \(scale 0\.5\)/);
});
test('the benchmark refuses a fast wrong answer, and the SQLite it measures is FerretDB\'s own setup', () => {
const run = read('run.go');
assert.match(run, /Correctness first: a fast wrong answer is no answer/);
assert.match(run, /the move was not stored/);
const sqlite = read('sqlite.go');
// FerretDB's defaults (internal/backends/sqlite/metadata/pool/uri.go).
for (const pragma of ['journal_mode(wal)', 'cache_size(-65536)', 'mmap_size(268435456)', 'temp_store(memory)', '_txlock=immediate']) {
assert.ok(sqlite.includes(pragma), pragma);
}
// negative: it is a module of its own, so FerretDB's and WeKan's builds never see these dependencies.
assert.match(read('go.mod'), /^module embeddedbench/m);
});
test('the README states the answer, and what the benchmark does not measure', () => {
const md = read('README.md');
assert.match(md, /\*\*Short answer: no\./);
assert.match(md, /fsync on every commit/);
assert.match(md, /does not measure what SQLite does in the engine/);
// Every entry of the awesome-go section is accounted for: tested, or a reason.
for (const name of ['go-sqlite', 'Pebble', 'Badger', 'bbolt', 'BuntDB', 'Nutsdb', 'Clover', 'goleveldb', 'rqlite', 'redka', 'Dolt', 'TiDB', 'CockroachDB', 'immudb', 'DiceDB', 'Dgraph', 'InfluxDB', 'Milvus', 'objectbox-go', 'CovenantSQL']) {
assert.ok(md.includes(name), name);
}
});
console.log(`embeddedEnginesBench: ${passed} tests passed`);
+62
View File
@@ -0,0 +1,62 @@
'use strict';
// A missing board, or a token that is too long, must not answer an export request
// with HTTP 200. PR #6869 fixed the Excel board route; the same two answers were in
// the PDF routes, so this pins the SHAPE of the fault over every export route and
// not only the one that was reported.
//
// Run: node tests/exportErrorStatuses.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const modelsDir = path.join(__dirname, '..', 'models');
const files = fs.readdirSync(modelsDir).filter(f => /^export.*\.js$/.test(f));
const read = f => fs.readFileSync(path.join(modelsDir, f), 'utf8').split('\n');
// every `res.end('literal error text')` and the last non-blank line before it
function endsWithoutStatus(lines) {
const found = [];
lines.forEach((line, i) => {
if (!/res\.end\((['"`]|TAPi18n)/.test(line)) return;
let j = i - 1;
while (j >= 0 && lines[j].trim() === '') j -= 1;
if (!/writeHead|statusCode|\.status\(/.test(lines[j])) found.push(`${i + 1}: ${line.trim()}`);
});
return found;
}
test('no export route ends a text answer without choosing its status', () => {
assert.ok(files.length >= 5, 'the export route files are found');
for (const f of files) {
assert.deepEqual(endsWithoutStatus(read(f)), [], `${f} answers with the default 200`);
}
});
test('negative: the scan does catch the fault shape', () => {
const bad = ["if (!board) {", " res.end('Board not found');", " return;", "}"];
assert.equal(endsWithoutStatus(bad).length, 1);
const good = ["if (!board) {", " res.writeHead(404, {});", " res.end('Board not found');", "}"];
assert.equal(endsWithoutStatus(good).length, 0);
});
test('"Board not found" is a 404 and a too-long token is a 400, in every export route', () => {
for (const f of files) {
const lines = read(f);
lines.forEach((line, i) => {
const m = /res\.end\('(Board not found|Invalid token)'\)/.exec(line);
if (!m) return;
let j = i - 1;
while (j >= 0 && lines[j].trim() === '') j -= 1;
const status = /writeHead\((\d+)/.exec(lines[j]);
assert.ok(status, `${f}:${i + 1} sets a status`);
if (m[1] === 'Board not found') assert.equal(status[1], '404', `${f}:${i + 1}`);
else assert.ok(['400', '401'].includes(status[1]), `${f}:${i + 1} is ${status[1]}`);
});
}
// the PDF routes are the ones that had it
const pdf = fs.readFileSync(path.join(modelsDir, 'exportPDF.js'), 'utf8');
assert.equal((pdf.match(/writeHead\(404[^)]*\);\s*res\.end\('Board not found'\)/g) || []).length, 2);
});
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// The full screen button in the header bar, to the right of the logo. The pure
// part (models/lib/fullscreen.js) is driven with fake documents - the unprefixed
// API, the webkit one, an iPhone, an iframe, a refusal - and the wiring is read
// from the source: the existing "Maximize" label, no new translation, the button
// only where the browser offers the API, and the browser's own events followed.
// tests/playwright/specs/header-fullscreen.e2e.js drives it in a browser.
//
// Run: node tests/fullscreenButton.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { isSupported, isFullscreen, toggle, CHANGE_EVENTS } = require('../models/lib/fullscreen');
const root = path.join(__dirname, '..');
const read = rel => fs.readFileSync(path.join(root, rel), 'utf8');
// A document that behaves like a browser's: requesting makes the element current.
function fakeDocument({ prefix = false, enabled = true, refuse = false } = {}) {
const doc = {};
const flag = prefix ? 'webkitFullscreenEnabled' : 'fullscreenEnabled';
const current = prefix ? 'webkitFullscreenElement' : 'fullscreenElement';
const request = prefix ? 'webkitRequestFullscreen' : 'requestFullscreen';
const exit = prefix ? 'webkitExitFullscreen' : 'exitFullscreen';
const calls = [];
doc[flag] = enabled;
doc[current] = null;
doc.documentElement = {
[request]() {
calls.push('request');
if (refuse) return Promise.reject(new TypeError('Permissions check failed'));
doc[current] = doc.documentElement;
return Promise.resolve();
},
};
doc[exit] = function () { calls.push('exit'); doc[current] = null; return Promise.resolve(); };
doc.calls = calls;
return doc;
}
test('supported only where the API is enabled, in either spelling', () => {
assert.equal(isSupported(fakeDocument()), true, 'unprefixed (Chrome, Firefox, Safari 16.4+)');
assert.equal(isSupported(fakeDocument({ prefix: true })), true, 'webkit-prefixed (older Safari, iPadOS before 16.4)');
// negative: an iframe without allowfullscreen, iPhone Safari and an old browser
assert.equal(isSupported(fakeDocument({ enabled: false })), false, 'fullscreenEnabled false');
assert.equal(isSupported(fakeDocument({ prefix: true, enabled: false })), false);
assert.equal(isSupported({}), false, 'neither name');
const iphone = fakeDocument();
delete iphone.fullscreenEnabled;
assert.equal(isSupported(iphone), false, 'iPhone: the property is missing');
assert.equal(isSupported(null), false);
assert.equal(isSupported(undefined), false);
});
test('toggle enters full screen and leaves it again, with either API', async () => {
for (const prefix of [false, true]) {
const doc = fakeDocument({ prefix });
assert.equal(isFullscreen(doc), false);
assert.equal(await toggle(doc), true, 'entered');
assert.equal(isFullscreen(doc), true);
assert.equal(await toggle(doc), false, 'left');
assert.equal(isFullscreen(doc), false);
assert.deepEqual(doc.calls, ['request', 'exit']);
}
});
test('negative: a refusal is an answer, not an error, and an unsupported page is left alone', async () => {
const refused = fakeDocument({ refuse: true });
assert.equal(await toggle(refused), false, 'the browser refused: not in full screen, and no throw');
assert.deepEqual(refused.calls, ['request']);
const iframe = fakeDocument({ enabled: false });
assert.equal(await toggle(iframe), false);
assert.deepEqual(iframe.calls, [], 'nothing is requested where it is not enabled');
// A request that throws at once, as an old WebKit does, is the same.
const throws = fakeDocument();
throws.documentElement.requestFullscreen = () => { throw new Error('boom'); };
assert.equal(await toggle(throws), false);
});
test('the button is drawn only where supported, beside the logo, with the existing Maximize label', () => {
const jade = read('client/components/main/header.jade');
const block = jade.slice(jade.indexOf('if fullscreenSupported'));
assert.match(block, /^if fullscreenSupported\n\s+a\.board-header-btn\.js-toggle-fullscreen\(href="#" role="button" title="\{\{#if isFullscreen\}\}\{\{_ 'minimize-window'\}\}\{\{else\}\}\{\{_ 'maximize-window'\}\}\{\{\/if\}\}" aria-label="\{\{#if isFullscreen\}\}\{\{_ 'minimize-window'\}\}\{\{else\}\}\{\{_ 'maximize-window'\}\}\{\{\/if\}\}" aria-pressed=/);
assert.match(block, /i\.fa\.fa-compress[\s\S]{0,60}i\.fa\.fa-expand/);
// After the logo, before the first collapsible icon, so hiding the icons keeps it.
assert.ok(jade.indexOf('div.header-logo') < jade.indexOf('js-toggle-fullscreen'), 'after the logo');
assert.ok(jade.indexOf('js-toggle-fullscreen') < jade.indexOf('.mobile-mode-toggle.js-header-collapsible-icon'), 'before the collapsible icons');
assert.doesNotMatch(block.split('\n').slice(0, 2).join('\n'), /js-header-collapsible-icon/, 'not itself collapsible');
// No new translation: the label is the key the popups already use.
assert.doesNotMatch(jade, /_ 'fullscreen|_ 'full-screen/);
const en = JSON.parse(read('imports/i18n/data/en.i18n.json'));
assert.equal(en['maximize-window'], 'Maximize');
assert.equal(en['minimize-window'], 'Minimize', 'the label while in full screen is the existing Minimize');
assert.ok(!Object.keys(en).some(k => /full-?screen/i.test(k)), 'no full screen key was added');
});
test('the header follows the browser\'s own events, in both spellings, and lets them go', () => {
assert.deepEqual(CHANGE_EVENTS, ['fullscreenchange', 'webkitfullscreenchange']);
const js = read('client/components/main/header.js');
assert.match(js, /fullscreen\.CHANGE_EVENTS\.forEach\(name => document\.addEventListener\(name, this\.onFullscreenChange\)\)/);
assert.match(js, /Template\.header\.onDestroyed\(function \(\) \{\n\s+if \(this\.onFullscreenChange\) \{\n\s+fullscreen\.CHANGE_EVENTS\.forEach\(name => document\.removeEventListener\(name, this\.onFullscreenChange\)\)/);
assert.match(js, /'click \.js-toggle-fullscreen'\(event\) \{\n\s+event\.preventDefault\(\);\n\s+fullscreen\.toggle\(document\);/);
assert.match(js, /this\.fullscreenSupported\.set\(fullscreen\.isSupported\(document\)\)/);
});
+108
View File
@@ -0,0 +1,108 @@
'use strict';
// Dependabot alerts #183-#187 (Go): the benchmark module of embedded engines
// (docs/Databases/FerretDB/1/SpeedTest/embedded, pure Go) pulled in
// github.com/klauspost/compress <= 1.18.6 GHSA-259r-337f-4rfw (s2 integer overflow, out-of-bounds write)
// golang.org/x/net < 0.55.0 GHSA-5cv4-jp36-h3mw (HTML parser denial of service)
// golang.org/x/net < 0.36.0 / < 0.38.0 GHSA-qxp5-gwg8-xv66, GHSA-vvgc-356p-c3xw (IPv6 zone ID proxy bypass, XSS)
// github.com/golang/glog < 1.2.4 GHSA-6wxm-mpqj-6jpf (insecure temporary file)
// through its key-value stores. govulncheck then listed more in the same modules,
// so the floors are the versions that report none: x/net 0.62.0, x/text 0.43.0 (which
// x/net 0.62.0 requires) and OpenTelemetry 1.42.0.
//
// The floors hold for EVERY go.mod in the repository, not only the one that was
// reported, so the same old version cannot return in another Go module.
//
// Run: node tests/goModuleFloors.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const FLOORS = {
'github.com/klauspost/compress': '1.18.7',
'golang.org/x/net': '0.62.0',
'golang.org/x/text': '0.43.0',
'github.com/golang/glog': '1.2.4',
'go.opentelemetry.io/otel': '1.42.0',
};
function versionParts(v) {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
// -1, 0 or 1; null for a version that is not a release number (a pseudo-version of a
// fork, say), which a floor cannot judge.
function compare(a, b) {
const x = versionParts(a);
const y = versionParts(b);
if (!x || !y) return null;
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
return 0;
}
// The modules a go.mod requires, with their versions: single-line and block form.
function requires(text) {
const out = [];
let inBlock = false;
for (const raw of text.split('\n')) {
const line = raw.replace(/\/\/.*$/, '').trim();
if (/^require\s*\($/.test(line)) { inBlock = true; continue; }
if (inBlock && line === ')') { inBlock = false; continue; }
const m = inBlock ? /^(\S+)\s+(\S+)$/.exec(line) : /^require\s+(\S+)\s+(\S+)$/.exec(line);
if (m) out.push({ module: m[1], version: m[2] });
}
return out;
}
function violations(text) {
return requires(text)
.filter(r => FLOORS[r.module] && compare(r.version, FLOORS[r.module]) === -1)
.map(r => `${r.module} ${r.version} is below ${FLOORS[r.module]}`);
}
function goModFiles(dir = '', out = []) {
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
// node_modules, the ignored tool and build directories (dot or _build) are not ours.
if (/^(node_modules|\.|_build)/.test(entry.name)) continue;
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) goModFiles(rel, out);
else if (entry.name === 'go.mod') out.push(rel);
}
return out;
}
const files = goModFiles();
assert.ok(files.includes(path.join('docs', 'Databases', 'FerretDB', '1', 'SpeedTest', 'embedded', 'go.mod')), 'the reported module is found');
// 1. Every go.mod of the repository is at or above every floor.
for (const file of files) {
assert.deepEqual(violations(fs.readFileSync(path.join(ROOT, file), 'utf8')), [], file);
}
// 2. The reported module requires the patched modules, not merely does not name old ones.
const reported = requires(fs.readFileSync(path.join(ROOT, 'docs/Databases/FerretDB/1/SpeedTest/embedded/go.mod'), 'utf8'));
for (const module of ['github.com/klauspost/compress', 'golang.org/x/net', 'github.com/golang/glog']) {
const r = reported.find(x => x.module === module);
assert.ok(r, `${module} is required`);
assert.notEqual(compare(r.version, FLOORS[module]), -1, `${module} ${r.version}`);
}
// 3. Negative: the check does reject what the alerts named, in both forms of go.mod.
assert.deepEqual(violations('module m\n\nrequire (\n\tgithub.com/klauspost/compress v1.18.6 // indirect\n\tgolang.org/x/net v0.54.0\n)\n'), [
'github.com/klauspost/compress v1.18.6 is below 1.18.7',
'golang.org/x/net v0.54.0 is below 0.62.0',
]);
assert.deepEqual(violations('module m\nrequire github.com/golang/glog v1.0.0\n'), ['github.com/golang/glog v1.0.0 is below 1.2.4']);
assert.deepEqual(violations('module m\nrequire (\n\tgithub.com/klauspost/compress v1.18.7\n\tgolang.org/x/net v0.62.0\n\tgolang.org/x/text v0.43.0\n)\n'), []);
// A v0.0.0 pseudo-version (an old commit of a module) is below every floor; a
// version that is not a number at all cannot be judged.
assert.equal(compare('v0.0.0-20230626212559-97b1e661b5df', '0.62.0'), -1);
assert.equal(compare('latest', '0.62.0'), null);
assert.equal(compare('v1.18.7', '1.18.7'), 0);
assert.equal(compare('v1.18.10', '1.18.7'), 1, 'numeric, not lexical: 10 > 7');
console.log(`goModuleFloors: ${files.length} go.mod file(s) at or above every floor, and the check rejects the versions the alerts named`);
+23 -6
View File
@@ -139,7 +139,15 @@ for (const platform of ['darwin', 'linux']) {
// A watcher that never reports anything: FSEvents in its first moments.
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
Object.defineProperty(process, 'platform', { value: platform });
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
// node:test runs after-hooks in the order they were registered, not last-in first-out. The engine
// must be stopped BEFORE its directory is removed: a hook that removed the directory first let
// stop() seal into a path that no longer existed, and the ENOENT surfaced after the test as
// "generated asynchronous activity after the test ended".
let stopEngine;
t.after(async () => {
await stopEngine?.();
fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true });
});
const live = path.join(root, 'live');
fs.mkdirSync(live, { recursive: true });
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
@@ -150,7 +158,7 @@ for (const platform of ['darwin', 'linux']) {
const scan = engine.scan.bind(engine);
engine.scan = () => { calls.push('scan'); return scan(); };
await engine.start();
t.after(() => engine.stop());
stopEngine = () => engine.stop();
assert.deepEqual(calls.slice(0, 2), ['watch', 'scan'], 'the watcher is up before the first scan');
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
for (let i = 0; i < 40 && engine.status().puts < 2; i += 1) await sleep(100);
@@ -175,7 +183,15 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
const calls = [];
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
Object.defineProperty(process, 'platform', { value: 'darwin' });
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
// node:test runs after-hooks in the order they were registered, not last-in first-out. The engine
// must be stopped BEFORE its directory is removed: a hook that removed the directory first let
// stop() seal into a path that no longer existed, and the ENOENT surfaced after the test as
// "generated asynchronous activity after the test ended".
let stopEngine;
t.after(async () => {
await stopEngine?.();
fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true });
});
const live = path.join(root, 'live');
fs.mkdirSync(live, { recursive: true });
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
@@ -188,7 +204,7 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
engine.scan = () => { calls.push('scan'); return scan(); };
await engine.start();
let stopped = false;
t.after(() => (stopped ? undefined : engine.stop()));
stopEngine = () => (stopped ? undefined : engine.stop());
if (stopAt === 'at once') { await engine.stop(); stopped = true; }
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
await sleep(2600); // past the two-second catch-up
@@ -200,7 +216,8 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
test('file streams: attachments, avatars and logs, with watching, restored to a moment', { timeout: 60000 }, async t => {
const root = tmpRoot();
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
let stopEngine;
t.after(async () => { await stopEngine?.(); fs.rmSync(root, { recursive: true, force: true }); }); // engine first: hooks run in registration order
const live = path.join(root, 'live');
const sources = [
{ area: 'attachments', key: 'attachments', path: path.join(live, 'attachments') },
@@ -223,7 +240,7 @@ test('file streams: attachments, avatars and logs, with watching, restored to a
onError: error => { throw error; }, onState: async s => { state = s; } }).start();
// Its seal and scan timers keep the process alive: stop it however this test
// ends, or a failed assertion below becomes a 300 s suite timeout.
t.after(() => engine.stop());
stopEngine = () => engine.stop();
assert.equal(engine.status().files, 4, 'the symbolic link is skipped');
// Watching notices a change without a rescan (the scan runs hourly here).
// File-system events can lag by seconds on a loaded machine: allow 20 s.
+36 -2
View File
@@ -1,6 +1,6 @@
'use strict';
// Dependabot alerts #180 and #182, GHSA-g3hv-75wg-m249 (CVE-2026-118764):
// Dependabot alerts #180 and #182 (and the exceljs lockfile alert), GHSA-g3hv-75wg-m249 (CVE-2026-118764):
// minimatch before 11.0.1 grows its glob parts without bound while it
// preprocesses an attacker's pattern (the "**/../" step clones an entry into
// the same array, inside a loop that runs until nothing changes), then
@@ -48,4 +48,38 @@ for (const name of ['readdir-glob', '@typescript-eslint/typescript-estree', '@ba
}
assert.match(pkg.devDependencies.minimatch, /^\^11\./, 'the root copy is declared, so every consumer finds it');
console.log('minimatchGlobparts: no minimatch 10.x in the lockfile, archiver, typescript-estree and babel use 11.0.1 or later');
// The same advisory in the second lockfile: npm-packages/exceljs (@wekanteam/exceljs),
// whose archiver reaches minimatch through readdir-glob 3, which asks for ^10.2.2.
// Alert #180 named that manifest and stayed open after the root lockfile was fixed.
const exceljsDir = 'npm-packages/exceljs';
const exceljsLock = read(`${exceljsDir}/package-lock.json`);
const exceljsPkg = read(`${exceljsDir}/package.json`);
const exceljsCopies = Object.entries(exceljsLock.packages).filter(([key]) => /(^|\/)node_modules\/minimatch$/.test(key));
assert.ok(exceljsCopies.length > 0, 'the exceljs lockfile installs minimatch');
for (const [key, entry] of exceljsCopies) {
assert.ok(Number(entry.version.split('.')[0]) >= 11 && entry.version !== '11.0.0', `${exceljsDir}: ${key} ${entry.version} is in the affected range`);
}
assert.match(String(exceljsPkg.overrides && exceljsPkg.overrides['readdir-glob'] && exceljsPkg.overrides['readdir-glob'].minimatch), /^\^11\./, 'exceljs: readdir-glob is overridden to minimatch 11');
// Negative: no lockfile in the repository outside node_modules keeps a minimatch in the affected 10.x line or at 11.0.0.
const walk = (dir, out = []) => {
for (const entry of fs.readdirSync(path.join(__dirname, '..', dir), { withFileTypes: true })) {
if (/^(node_modules|\.|_build)/.test(entry.name) || entry.name === 'log') continue;
const rel = path.join(dir, entry.name);
if (entry.isDirectory()) walk(rel, out);
else if (entry.name === 'package-lock.json') out.push(rel);
}
return out;
};
// The whole repository: every lockfile outside node_modules and the ignored tool
// and build directories (those start with a dot or _build).
const lockfiles = ['package-lock.json', ...fs.readdirSync(path.join(__dirname, '..'), { withFileTypes: true })
.filter(e => e.isDirectory() && !/^(node_modules|\.|_build)/.test(e.name)).flatMap(e => walk(e.name))];
for (const file of new Set(lockfiles)) {
for (const [key, entry] of Object.entries(read(file).packages || {})) {
if (!/(^|\/)node_modules\/minimatch$/.test(key)) continue;
assert.doesNotMatch(entry.version, /^10\./, `${file}: ${key} ${entry.version}`);
assert.notEqual(entry.version, '11.0.0', `${file}: ${key}`);
}
}
console.log('minimatchGlobparts: no minimatch 10.x in any lockfile, archiver, typescript-estree, babel and exceljs use 11.0.1 or later');
+7 -2
View File
@@ -117,7 +117,7 @@ function value(literal) {
// replacement holding a $, which can name what was matched.
const REGEX_IDENTITY = new RegExp(
'\\.replace(?:All)?\\(\\s*' +
'/((?:[^/\\\\\\n.*+?^$|()[\\]{}]|\\\\[^a-zA-Z0-9\\n])+)/([a-z]*)' +
'/(\\^?(?:[^/\\\\\\n.*+?^$|()[\\]{}]|\\\\[^a-zA-Z0-9\\n])+\\$?)/([a-z]*)' +
'\\s*,\\s*' +
"(?:'((?:[^'\\\\]|\\\\.)*)'|\"((?:[^\"\\\\]|\\\\.)*)\")" +
'\\s*\\)',
@@ -128,7 +128,10 @@ function regexIdentity(m) {
const [, source, flags] = m;
const replacement = m[3] !== undefined ? m[3] : m[4];
if (flags.includes('i') || replacement.includes('$')) return false;
return source.replace(/\\(.)/g, '$1') === value(replacement);
// A leading ^ or trailing $ only anchors the match; replacing the anchored text
// with itself is still a no-op (tests/boardLabelLookup.test.cjs: /^getLabel/).
const literal = source.replace(/^\^|(?<!\\)\$$/g, '');
return literal.replace(/\\(.)/g, '$1') === value(replacement);
}
// Comments are stripped before scanning. This file and the one it was written
@@ -185,6 +188,8 @@ test('the pattern really does catch the shape CodeQL reported (negative)', () =>
"new RegExp(`^## ${h.replace(/ /g, ' ')}$`, 'm')",
"s.replace(/-/g, '-')",
"s.replace(/\\./g, '.')",
"src.replace(/^getLabel/, 'getLabel')",
"src.replace(/end$/, 'end')",
"raw.replace(/\"/g, '\\\"')",
]) {
assert.strictEqual([...sample.matchAll(REGEX_IDENTITY)].filter(regexIdentity).length, 1, `not detected: ${sample}`);
@@ -0,0 +1,58 @@
'use strict';
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const BoardPage = require('../pages/BoardPage');
const ExcelJS = require('../../../node_modules/@wekanteam/exceljs');
async function expectWorkbook(response) {
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('spreadsheetml.sheet');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(await response.body());
const values = [];
workbook.eachSheet(sheet => sheet.eachRow(row => values.push(JSON.stringify(row.values))));
expect(values.join('\n')).toContain('Alpha Card');
}
test('a missing board Excel export returns 404 with or without a token', async ({ request, user }) => {
const url = `/api/boards/${db.uid('missing')}/exportExcel`;
for (const suffix of ['', `?authToken=${encodeURIComponent(user.token)}`]) {
const response = await request.get(url + suffix);
expect(response.status()).toBe(404);
expect(response.headers()['content-type']).toBe('text/plain; charset=utf-8');
expect(response.headers()['content-disposition']).toBeUndefined();
expect(await response.text()).toBe('Board not found');
}
});
test('the board menu exports Excel and a deleted board link returns 404', async ({ boardPage: page, board }) => {
await new BoardPage(page).openSidebar();
await page.locator('.board-sidebar .js-open-board-menu').click();
await page.locator('.js-pop-over .js-export-board').click();
const anchor = page.locator('.js-pop-over a[href*="/exportExcel?"]');
await expect(anchor).toBeVisible();
const href = await anchor.getAttribute('href');
await expectWorkbook(await page.request.get(href));
db.deleteOne('boards', { _id: board.boardId });
const response = await page.goto(href);
expect(response.status()).toBe(404);
expect(response.headers()['content-type']).toBe('text/plain; charset=utf-8');
expect(response.headers()['content-disposition']).toBeUndefined();
expect(await response.text()).toBe('Board not found');
await expect(page.locator('body')).toHaveText('Board not found');
});
test('public board Excel exports still produce both workbook layouts', async ({ request, board }) => {
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'public' } });
for (const suffix of ['', '?fields=description']) {
await expectWorkbook(await request.get(`/api/boards/${board.boardId}/exportExcel${suffix}`));
}
});
test('private board Excel exports still refuse an unrelated member', async ({ request, board, user2 }) => {
const response = await request.get(`/api/boards/${board.boardId}/exportExcel?authToken=${encodeURIComponent(user2.token)}`);
expect(response.status()).toBe(403);
expect(await response.text()).not.toContain('Alpha Card');
});
@@ -0,0 +1,64 @@
'use strict';
// iPad Safari and Chrome (both WebKit) make every SCROLLING element - #content, the
// board wrapper - a stacking context of its own. The opened card is inside them, so its
// z-index (2001) is compared only there, and the header (#header-quick-access, z-index
// 1000, a flex item of body and so a stacking context too) is painted over the card: its top row - the collapse caret, the card
// number, the menu, maximize and X - sat behind the header bar (boards.wekan.team,
// iPad Chrome, 2026-11-04). Other browsers do not do it. The trap is simulated here with
// isolation: isolate, which makes the same stacking context, so Chromium and WebKit
// show what the iPad does. Desktop mode, forced as the toggle does.
const { test, expect } = require('../fixtures');
const BoardPage = require('../pages/BoardPage');
async function overHeader(page) {
return page.evaluate(() => {
const card = document.querySelector('.card-details');
const header = document.querySelector('#header-quick-access');
const c = card.getBoundingClientRect();
const h = header.getBoundingClientRect();
const top = Math.max(c.top, h.top);
const bottom = Math.min(c.bottom, h.bottom);
if (bottom - top < 8) return { overlaps: false };
// The card's own header row: the caret, the number, the menu and X are drawn there.
const y = Math.round(top + 6);
const x = Math.round(c.left + Math.min(40, c.width / 2));
const hit = document.elementFromPoint(x, y);
const closeBtn = card.querySelector('.js-close-card-details').getBoundingClientRect();
const closeHit = document.elementFromPoint(Math.round(closeBtn.left + closeBtn.width / 2), Math.round(Math.max(closeBtn.top, h.top) + 4));
return {
overlaps: true,
cardIsTopmost: !!hit && card.contains(hit),
closeButtonIsTopmost: !!closeHit && card.contains(closeHit),
cardTop: Math.round(c.top), headerBottom: Math.round(h.bottom),
};
});
}
for (const trap of ['#content', '.board-wrapper', '#content, .board-wrapper']) {
test(`the opened card covers the header when ${trap} is a stacking context (iOS WebKit), open and collapsed`, async ({ boardPage: page, board }) => {
await page.evaluate(() => localStorage.setItem('wekan-mobile-mode', 'false'));
await page.reload();
await new BoardPage(page).clickCard(board.listIds[0], 'Alpha Card');
await expect(page.locator('.card-details')).toBeVisible();
await expect(page.locator('body')).toHaveClass(/desktop-mode/);
await page.addStyleTag({ content: `${trap} { isolation: isolate; }` });
const open = await overHeader(page);
expect(open.overlaps, 'the docked card overlaps the header').toBe(true);
expect(open.cardIsTopmost, `the card is drawn over the header (card top ${open.cardTop}, header bottom ${open.headerBottom})`).toBe(true);
expect(open.closeButtonIsTopmost, 'and its close button can be reached').toBe(true);
await page.locator('.card-details .card-collapse-toggle').first().click();
await expect(page.locator('.card-details.card-details-collapsed')).toBeVisible();
const collapsed = await overHeader(page);
expect(collapsed.overlaps).toBe(true);
expect(collapsed.cardIsTopmost, 'collapsed too').toBe(true);
});
}
test('with no card open the header is where it was (negative)', async ({ boardPage: page }) => {
await expect(page.locator('.card-details')).toHaveCount(0);
const z = await page.evaluate(() => getComputedStyle(document.querySelector('#header-quick-access')).zIndex);
expect(z, 'the header keeps its own level while no card is open').toBe('1000');
});
@@ -0,0 +1,56 @@
'use strict';
// An opened card covers the header bars in every browser. A card opened as a POPUP
// (Session popupCardId: a search result, a table row, a Roadmap/Gantt bar) used to
// open at top: 97px - BELOW the header - and, collapsed, was a bar floating over the
// swimlane. It now docks at the top like every other opened card. Desktop mode is
// forced, as the mobile/desktop toggle does.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const BoardPage = require('../pages/BoardPage');
// Where the card is, where the header is, and what is topmost where they overlap.
async function stacking(page) {
return page.evaluate(() => {
const card = document.querySelector('.card-details');
const header = document.querySelector('#header-quick-access');
const c = card.getBoundingClientRect();
const h = header.getBoundingClientRect();
const y = Math.round((Math.max(c.top, h.top) + Math.min(c.bottom, h.bottom)) / 2);
const x = Math.round(c.left + Math.min(40, c.width / 2));
const overlaps = Math.min(c.bottom, h.bottom) > Math.max(c.top, h.top) + 4;
const top = overlaps ? document.elementFromPoint(x, y) : null;
return {
cardTop: Math.round(c.top), headerBottom: Math.round(h.bottom), overlaps,
cardIsTopmost: !!top && card.contains(top), popup: card.classList.contains('card-details-popup'),
z: getComputedStyle(card).zIndex,
};
});
}
test('a card opened as a popup covers the header, expanded and collapsed', async ({ boardPage: page, board }) => {
await page.evaluate(() => localStorage.setItem('wekan-mobile-mode', 'false'));
await page.reload();
const bp = new BoardPage(page);
await bp.clickCard(board.listIds[0], 'Alpha Card');
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
await page.evaluate(id => Session.set('popupCardId', id), card._id);
await expect(page.locator('.card-details.card-details-popup')).toBeVisible();
await expect(page.locator('body')).toHaveClass(/desktop-mode/);
const open = await stacking(page);
expect(open.popup).toBe(true);
expect(open.cardTop, 'docked at the top, not at 97px below the header').toBeLessThan(open.headerBottom);
expect(open.cardTop).toBeLessThanOrEqual(16);
expect(open.overlaps, 'it overlaps the header').toBe(true);
expect(open.cardIsTopmost, 'and is drawn over it').toBe(true);
expect(Number(open.z)).toBeGreaterThan(1000);
// Collapsed: only the title bar is left, and it too is over the header, not below it.
await page.locator('.card-details .card-collapse-toggle').first().click();
await expect(page.locator('.card-details.card-details-collapsed')).toBeVisible();
const collapsed = await stacking(page);
expect(collapsed.cardTop).toBeLessThanOrEqual(16);
expect(collapsed.overlaps).toBe(true);
expect(collapsed.cardIsTopmost).toBe(true);
});
@@ -0,0 +1,156 @@
'use strict';
// The opened card's window moves under a FINGER. It was moved by mousedown / mousemove
// / mouseup only; a finger sends none of them while it moves, so on an iPad the page
// behind the card scrolled and the card stayed where it was - dragged from the collapse
// caret, the space beside it, the card number, or the drag handle. Title editing is
// unaffected (a tap on the title edits). Chromium only: the touch comes from the
// DevTools protocol, which produces the same Pointer Events (pointerType "touch") a
// finger does, and honours touch-action.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const BoardPage = require('../pages/BoardPage');
async function touchDrag(cdp, from, to, steps = 12) {
const point = (x, y) => ({ x, y, id: 1 });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point(from.x, from.y)] });
for (let i = 1; i <= steps; i += 1) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [point(from.x + ((to.x - from.x) * i) / steps, from.y + ((to.y - from.y) * i) / steps)],
});
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
}
const cardBox = page => page.evaluate(() => {
const b = document.querySelector('.card-details').getBoundingClientRect();
return { x: Math.round(b.x), y: Math.round(b.y) };
});
const centre = async locator => {
const b = await locator.boundingBox();
return { x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) };
};
async function openCardWindow(page, board, user, { handles }) {
db.updateOne('users', { _id: user.id }, { $set: { 'profile.showDesktopDragHandles': handles } });
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsCardNumber: true } });
await page.evaluate(() => localStorage.setItem('wekan-mobile-mode', 'false'));
await page.reload();
await new BoardPage(page).clickCard(board.listIds[0], 'Alpha Card');
await expect(page.locator('.card-details')).toBeVisible();
await expect(page.locator('body')).toHaveClass(/desktop-mode/);
if (handles) await expect(page.locator('.js-card-drag-handle')).toBeVisible();
else await expect(page.locator('.js-card-drag-handle')).toHaveCount(0);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
return cdp;
}
test('a finger moves the card window from the collapse caret, the space beside it and the card number', async ({ boardPage: page, board, user, browserName }) => {
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
const cdp = await openCardWindow(page, board, user, { handles: false });
const spots = {
'the collapse caret': () => centre(page.locator('.card-details .card-collapse-toggle')),
'the card number': () => centre(page.locator('.card-details .card-number')),
'the space between the title and the menu': async () => {
const title = await page.locator('.card-details .card-details-title').boundingBox();
const menu = await page.locator('.card-details .js-open-card-details-menu').boundingBox();
const header = await page.locator('.card-details .card-details-header').boundingBox();
return { x: Math.round((title.x + title.width + menu.x) / 2), y: Math.round(header.y + 18) };
},
};
const inline = await page.evaluate(() => { const c = document.querySelector('.card-details'); return { left: c.style.left, top: c.style.top }; });
for (const [name, where] of Object.entries(spots)) {
const before = await cardBox(page);
const from = await where();
await touchDrag(cdp, from, { x: from.x - 120, y: from.y + 90 });
await expect.poll(() => cardBox(page), { message: `a finger drag from ${name} moves the window` })
.toMatchObject({ x: before.x - 120, y: before.y + 90 });
// put it back for the next one
await page.evaluate(({ left, top }) => { const c = document.querySelector('.card-details'); c.style.left = left; c.style.top = top; }, inline);
await expect.poll(() => cardBox(page)).toMatchObject(before);
}
});
test('a tap on the collapse caret still collapses the card, and the title still edits', async ({ boardPage: page, board, user, browserName }) => {
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
const cdp = await openCardWindow(page, board, user, { handles: false });
const before = await cardBox(page);
// A tap: press and release on the caret with no movement.
const caret = await centre(page.locator('.card-details .card-collapse-toggle'));
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: caret.x, y: caret.y, id: 1 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect(page.locator('.card-details.card-details-collapsed'), 'the tap collapses it').toBeVisible();
expect(await cardBox(page), 'and does not move it').toMatchObject(before);
await page.locator('.card-details .card-collapse-toggle').click();
await expect(page.locator('.card-details.card-details-collapsed')).toHaveCount(0);
// The title edits when it is tapped (not dragged from).
await page.locator('.card-details .card-details-title').click();
await expect(page.locator('.card-details textarea.js-edit-card-title, .card-details .js-card-details-title textarea')).toBeVisible();
});
test('a drag that starts on the caret moves the window and does not also collapse the card', async ({ boardPage: page, board, user, browserName }) => {
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
const cdp = await openCardWindow(page, board, user, { handles: false });
const before = await cardBox(page);
const from = await centre(page.locator('.card-details .card-collapse-toggle'));
await touchDrag(cdp, from, { x: from.x + 60, y: from.y + 40 });
await expect.poll(() => cardBox(page), { message: 'it moved' }).toMatchObject({ x: before.x + 60, y: before.y + 40 });
await expect(page.locator('.card-details.card-details-collapsed'), 'the click after a drag is swallowed').toHaveCount(0);
});
test('a popup moves under a finger by its title bar', async ({ boardPage: page, browserName }) => {
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
await page.locator('.js-open-header-member-menu').first().click();
const header = page.locator('.pop-over .header').first();
await expect(header).toBeVisible();
const box = () => page.evaluate(() => { const b = document.querySelector('.pop-over').getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y) }; });
const before = await box();
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
// The title bar beside its title, away from the back and close buttons.
const h = await header.boundingBox();
const from = { x: Math.round(h.x + h.width / 2), y: Math.round(h.y + h.height / 2) };
await touchDrag(cdp, from, { x: from.x - 90, y: from.y + 70 });
await expect.poll(box, { message: 'a finger on the title bar moves the popup' }).toMatchObject({ x: before.x - 90, y: before.y + 70 });
});
test('with drag handles on, the handle moves the window under a finger and the header row does not', async ({ boardPage: page, board, user, browserName }) => {
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
const cdp = await openCardWindow(page, board, user, { handles: true });
const before = await cardBox(page);
// Negative: the header row is not the way when handles are on - not the caret (a
// control, which would otherwise wait for a drag), not the card number.
for (const locator of [page.locator('.card-details .card-collapse-toggle'), page.locator('.card-details .card-number')]) {
const from = await centre(locator);
await touchDrag(cdp, from, { x: from.x - 100, y: from.y + 80 });
expect(await cardBox(page), 'the header row does not move it').toMatchObject(before);
}
// The handle does.
const handle = await centre(page.locator('.card-details .js-card-drag-handle'));
await touchDrag(cdp, handle, { x: handle.x - 140, y: handle.y + 100 });
await expect.poll(() => cardBox(page), { message: 'a finger on the handle moves the window' })
.toMatchObject({ x: before.x - 140, y: before.y + 100 });
});
test('a mouse still moves the window by the header row', async ({ boardPage: page, board, user }) => {
await openCardWindow(page, board, user, { handles: false });
const before = await cardBox(page);
const header = await page.locator('.card-details .card-details-header').boundingBox();
const title = await page.locator('.card-details .card-details-title').boundingBox();
const menu = await page.locator('.card-details .js-open-card-details-menu').boundingBox();
const x = Math.round((title.x + title.width + menu.x) / 2);
const y = Math.round(header.y + 18);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x - 80, y + 60, { steps: 8 });
await page.mouse.up();
await expect.poll(() => cardBox(page)).toMatchObject({ x: before.x - 80, y: before.y + 60 });
});
@@ -0,0 +1,68 @@
'use strict';
// A full screen button to the right of the logo in the header bar (iPad Safari has
// the Fullscreen API; so do desktop browsers and Android). It uses the existing
// "Maximize" label, is drawn only where the browser offers the API and the page may
// use it, and follows the browser's own full screen events.
const { test, expect } = require('../fixtures');
const inFullscreen = page => page.evaluate(() => !!(document.fullscreenElement || document.webkitFullscreenElement));
test('the header has a full screen button beside the logo that enters and leaves full screen', async ({ boardPage: page, browserName }) => {
const button = page.locator('.js-toggle-fullscreen');
await expect(button, 'drawn where the browser has the Fullscreen API').toBeVisible();
// The existing "Maximize" label: a title and an accessible name, and not pressed yet.
await expect(button).toHaveAttribute('title', 'Maximize');
await expect(button).toHaveAttribute('aria-label', 'Maximize');
await expect(button).toHaveAttribute('aria-pressed', 'false');
await expect(button.locator('.fa-expand')).toBeVisible();
// To the right of the logo, in the same bar.
const logo = page.locator('#header-quick-access .header-logo').first();
const logoBox = await logo.boundingBox();
const buttonBox = await button.boundingBox();
expect(logoBox && buttonBox).toBeTruthy();
expect(buttonBox.x, 'the button is to the right of the logo').toBeGreaterThan(logoBox.x + logoBox.width - 1);
// Firefox could not be launched where this was written, so entering is not asserted there.
test.skip(browserName === 'firefox', 'full screen entering not verified on Firefox');
expect(await inFullscreen(page)).toBe(false);
await button.click();
await expect.poll(() => inFullscreen(page), { message: 'the click enters full screen' }).toBe(true);
await expect(button).toHaveAttribute('aria-pressed', 'true');
await expect(button.locator('.fa-compress')).toBeVisible();
// In full screen the same button says how to leave: the existing "Minimize".
await expect(button).toHaveAttribute('title', 'Minimize');
await expect(button).toHaveAttribute('aria-label', 'Minimize');
// Leaving by the button...
await button.click();
await expect.poll(() => inFullscreen(page)).toBe(false);
await expect(button).toHaveAttribute('aria-pressed', 'false');
await expect(button.locator('.fa-expand')).toBeVisible();
await expect(button, 'and back to Maximize').toHaveAttribute('title', 'Maximize');
// ...and by the browser's own way out (Esc), which the icon must follow.
await button.click();
await expect.poll(() => inFullscreen(page)).toBe(true);
await page.evaluate(() => (document.exitFullscreen || document.webkitExitFullscreen).call(document));
await expect.poll(() => inFullscreen(page)).toBe(false);
await expect(button).toHaveAttribute('aria-pressed', 'false');
await expect(button.locator('.fa-expand')).toBeVisible();
});
test('negative: no button where the page may not use full screen (iPhone, an iframe without allowfullscreen)', async ({ boardPage: page }) => {
await expect(page.locator('.js-toggle-fullscreen')).toHaveCount(1);
// What iPhone Safari and a plain iframe say: the API is not enabled.
await page.addInitScript(() => {
for (const name of ['fullscreenEnabled', 'webkitFullscreenEnabled']) {
Object.defineProperty(Document.prototype, name, { get: () => false, configurable: true });
}
});
await page.reload();
await expect(page.locator('#header-quick-access')).toBeVisible();
await expect(page.locator('.js-toggle-fullscreen'), 'not drawn').toHaveCount(0);
// The rest of the bar is there.
await expect(page.locator('#header-quick-access .header-logo').first()).toBeVisible();
});
@@ -0,0 +1,83 @@
'use strict';
// A scoped checklist GET must distinguish not-found from server errors.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const BoardPage = require('../pages/BoardPage');
const CardPage = require('../pages/CardPage');
const headers = token => ({ Authorization: `Bearer ${token}` });
const checklistPath = (boardId, cardId, checklistId = '') =>
`/api/boards/${boardId}/cards/${cardId}/checklists${checklistId ? `/${checklistId}` : ''}`;
async function createChecklist(request, user, board) {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
const response = await request.post(checklistPath(board.boardId, card._id), {
headers: headers(user.token),
data: { title: 'Retained REST checklist', items: ['Retained REST item'] },
});
expect(response.status()).toBe(200);
const { _id } = await response.json();
return { card, id: _id, path: checklistPath(board.boardId, card._id, _id) };
}
test('missing and wrong-scope checklists return JSON 404 while a valid checklist stays readable', async ({ request, user, board }) => {
const checklist = await createChecklist(request, user, board);
const otherCard = db.findOne('cards', { boardId: board.boardId, title: 'Beta Card' });
const otherBoard = db.seedBoard({ ownerId: user.id, cardTitlesPerList: [['Other board card']] });
try {
const foreignCard = db.findOne('cards', { boardId: otherBoard.boardId });
for (const path of [
checklistPath(board.boardId, checklist.card._id, 'missing-checklist'),
checklistPath(board.boardId, otherCard._id, checklist.id),
checklistPath(otherBoard.boardId, foreignCard._id, checklist.id),
]) {
const response = await request.get(path, { headers: headers(user.token) });
expect(response.status()).toBe(404);
expect(response.headers()['content-type']).toContain('application/json');
expect(await response.json()).toEqual({ error: 'Checklist not found or does not belong to the specified card' });
}
for (const cardId of ['missing-card', foreignCard._id]) {
const response = await request.get(checklistPath(board.boardId, cardId, checklist.id), { headers: headers(user.token) });
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({ error: 'Card not found or does not belong to the specified board' });
}
const response = await request.get(checklist.path, { headers: headers(user.token) });
expect(response.status()).toBe(200);
const data = await response.json();
expect(data._id).toBe(checklist.id);
expect(data.title).toBe('Retained REST checklist');
expect(data.items.map(item => item.title)).toEqual(['Retained REST item']);
} finally {
db.cleanup({ boardIds: [otherBoard.boardId] });
}
});
test('missing and existing checklist IDs preserve board access and authentication errors', async ({ request, user, user2, board }) => {
const checklist = await createChecklist(request, user, board);
for (const id of [checklist.id, 'missing-checklist']) {
const path = checklistPath(board.boardId, checklist.card._id, id);
expect((await request.get(path, { headers: headers(user2.token) })).status()).toBe(403);
expect((await request.get(path)).status()).toBe(401);
}
});
test('a failed checklist read leaves the real checklist and its item visible in the card', async ({ request, user, board, boardPage }) => {
const checklist = await createChecklist(request, user, board);
const bp = new BoardPage(boardPage);
const cp = new CardPage(boardPage);
await bp.clickCard(board.listIds[0], 'Alpha Card');
await cp.waitForOpen();
await expect(cp.root.locator('.checklist-title').filter({ hasText: 'Retained REST checklist' })).toBeVisible();
await expect(cp.root.locator('.js-checklist-item').filter({ hasText: 'Retained REST item' })).toBeVisible();
const response = await request.get(checklistPath(board.boardId, checklist.card._id, 'missing-checklist'), { headers: headers(user.token) });
expect(response.status()).toBe(404);
await boardPage.reload();
// Open-card state belongs to the page session, so reopen after a reload.
await bp.clickCard(board.listIds[0], 'Alpha Card');
await cp.waitForOpen();
await cp.root.locator('.checklist-title').filter({ hasText: 'Retained REST checklist' }).scrollIntoViewIfNeeded();
await expect(cp.root.locator('.checklist-title').filter({ hasText: 'Retained REST checklist' })).toBeVisible();
await expect(cp.root.locator('.js-checklist-item').filter({ hasText: 'Retained REST item' })).toBeVisible();
});
@@ -0,0 +1,101 @@
'use strict';
const { test: base, expect } = require('../fixtures');
const db = require('../helpers/db');
const headers = token => ({ Authorization: `Bearer ${token}` });
const integrationPath = (boardId, id) => `/api/boards/${boardId}/integrations/${id}`;
const test = base.extend({
integration: async ({ request, user, board }, use) => {
const response = await request.post(`/api/boards/${board.boardId}/integrations`, {
headers: headers(user.token), data: { url: 'https://example.com/webhook' },
});
expect(response.status()).toBe(200);
const { _id } = await response.json();
try {
await use({ id: _id, path: integrationPath(board.boardId, _id) });
} finally {
db.deleteOne('integrations', { _id });
}
},
});
test('integration PUT rejects missing and wrong-board IDs and preserves real updates', async ({ request, user, board, integration }) => {
const other = db.seedBoard({ ownerId: user.id });
try {
const before = db.findOne('integrations', { _id: integration.id });
for (const path of [
integrationPath(board.boardId, 'missing-integration'),
integrationPath(other.boardId, integration.id),
]) {
for (const data of [{}, { title: 'Should not change', enabled: false, activities: [] }]) {
const response = await request.put(path, { headers: headers(user.token), data });
expect(response.status()).toBe(404);
expect(response.headers()['content-type']).toContain('application/json');
expect(await response.json()).toEqual({ error: 'Integration not found' });
expect(db.findOne('integrations', { _id: integration.id })).toEqual(before);
}
}
expect(db.findOne('integrations', { _id: 'missing-integration' })).toBeNull();
for (const data of [{ title: 'Updated webhook', enabled: false, activities: [] }, { title: 'Updated webhook', enabled: false }, {}]) {
const response = await request.put(integration.path, { headers: headers(user.token), data });
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ _id: integration.id });
expect(db.findOne('integrations', { _id: integration.id })).toMatchObject({ title: 'Updated webhook', enabled: false, activities: [] });
}
const response = await request.get(integration.path, { headers: headers(user.token) });
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ _id: integration.id, title: 'Updated webhook', enabled: false, activities: [] });
} finally {
db.cleanup({ boardIds: [other.boardId] });
}
});
test('integration PUT preserves authentication and board-admin requirements', async ({ request, board, integration }) => {
const before = db.findOne('integrations', { _id: integration.id });
for (const id of [integration.id, 'missing-integration']) {
const path = integrationPath(board.boardId, id);
expect((await request.put(path, { data: { title: 'Unauthorized' } })).status()).toBe(401);
// A refused management request may block its actor. Use a fresh ordinary
// member for each ID so each request exercises the board-admin guard.
const member = db.seedUser();
try {
db.updateOne('boards', { _id: board.boardId }, { $push: { members: {
userId: member.id, isActive: true, isAdmin: false, isNoComments: false, isCommentOnly: false,
} } });
expect((await request.put(path, { headers: headers(member.token), data: { title: 'Forbidden' } })).status()).toBe(403);
} finally {
db.cleanup({ userIds: [member.id] });
}
}
expect(db.findOne('integrations', { _id: integration.id })).toEqual(before);
});
test('successful REST updates appear in webhook settings after a wrong-board update is rejected', async ({ request, user, board, integration, boardPage }) => {
const other = db.seedBoard({ ownerId: user.id });
try {
const response = await request.put(integration.path, {
headers: headers(user.token), data: { title: 'Retained webhook', enabled: false, activities: [] },
});
expect(response.status()).toBe(200);
const rejected = await request.put(integrationPath(other.boardId, integration.id), {
headers: headers(user.token), data: { title: 'Wrong-board overwrite', enabled: true },
});
expect(rejected.status()).toBe(404);
const menu = boardPage.locator('.board-sidebar .js-open-board-menu');
if (!(await menu.isVisible())) await boardPage.locator('.js-toggle-page-sidebar').first().click();
await menu.click();
await boardPage.locator('.js-outgoing-webhooks').click();
const form = boardPage.locator('form.integration-form').filter({
has: boardPage.locator(`input[name="id"][value="${integration.id}"]`),
});
await expect(form.locator('.js-outgoing-webhooks-title')).toHaveValue('Retained webhook');
await expect(form.locator('.js-toggle-webhook-enabled .materialCheckBox')).toHaveClass(/is-checked/);
expect(db.findOne('integrations', { _id: integration.id })).toMatchObject({ title: 'Retained webhook', enabled: false });
} finally {
db.cleanup({ boardIds: [other.boardId] });
}
});
+114
View File
@@ -0,0 +1,114 @@
'use strict';
// A missing or incorrectly scoped checklist is a 404, not a server
// error. Exercise the registered GET handler, including its parent and access
// checks, without starting Meteor.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const { checklistWithItems } = require('../server/lib/checklistDeadlines');
const route = '/api/boards/:boardId/cards/:cardId/checklists/:checklistId';
const missing = { code: 404, data: { error: 'Checklist not found or does not belong to the specified card' } };
const plain = value => JSON.parse(JSON.stringify(value));
function fixture({ deny = false, lookupError } = {}) {
const calls = [];
const cards = [
{ _id: 'card', boardId: 'board' },
{ _id: 'other-card', boardId: 'board' },
{ _id: 'private-card', boardId: 'private-board' },
];
const checklists = [
{ _id: 'checklist', cardId: 'card', title: 'Steps', dueAt: '2026-11-04T10:00:00.000Z' },
{ _id: 'private-checklist', cardId: 'private-card', title: 'Private steps' },
];
const items = [{ _id: 'item', checklistId: 'checklist', cardId: 'card', title: 'First step', isFinished: false }];
const matches = (doc, selector) => Object.entries(selector).every(([key, value]) => doc[key] === value);
const find = (name, docs) => async selector => {
calls.push([name, plain(selector)]);
if (lookupError && name === 'checklist') throw lookupError;
return docs.find(doc => matches(doc, selector));
};
let handler;
const context = {
WebApp: { handlers: Object.fromEntries(['get', 'post', 'put', 'delete'].map(verb => [verb, (url, fn) => {
if (verb === 'get' && url === route) handler = fn;
}])) },
Authentication: { async checkBoardAccess(userId, boardId) {
calls.push(['access', userId, boardId]);
if (deny || boardId !== 'board') throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
} },
ReactiveCache: {
getCard: find('card', cards),
getChecklist: find('checklist', checklists),
async getChecklistItems(selector) {
calls.push(['items', plain(selector)]);
return items.filter(doc => matches(doc, selector));
},
},
checklistWithItems,
sendJsonResult(res, result) { res.responses.push(plain(result)); },
};
const source = fs.readFileSync(path.join(__dirname, '../server/models/checklists.js'), 'utf8');
vm.runInNewContext(source.slice(source.indexOf('WebApp.handlers.get(')), context);
assert.equal(typeof handler, 'function');
return {
calls,
async get(params = {}) {
const res = { responses: [] };
await handler({ userId: 'member', params: { boardId: 'board', cardId: 'card', checklistId: 'checklist', ...params } }, res);
assert.equal(res.responses.length, 1, 'respond exactly once');
return res.responses[0];
},
};
}
test('a valid checklist retains its title, deadline and items', async () => {
const f = fixture();
assert.deepEqual(await f.get(), { code: 200, data: {
_id: 'checklist', cardId: 'card', title: 'Steps', dueAt: '2026-11-04T10:00:00.000Z',
items: [{ _id: 'item', title: 'First step', isFinished: false, dueAt: null }],
} });
assert.deepEqual(f.calls, [
['access', 'member', 'board'],
['card', { _id: 'card', boardId: 'board' }],
['checklist', { _id: 'checklist', cardId: 'card' }],
['items', { checklistId: 'checklist', cardId: 'card' }],
]);
});
for (const [name, params] of [
['missing checklist', { checklistId: 'missing' }],
['checklist of another card on the same board', { cardId: 'other-card' }],
['checklist of another board', { checklistId: 'private-checklist' }],
]) {
test(`${name} returns a descriptive 404 without loading items`, async () => {
const f = fixture();
assert.deepEqual(await f.get(params), missing);
assert.ok(!f.calls.some(([name]) => name === 'items'));
});
}
for (const cardId of ['missing-card', 'private-card']) {
test(`${cardId} fails the parent check before looking up the checklist`, async () => {
const f = fixture();
assert.deepEqual(await f.get({ cardId }), { code: 404, data: { error: 'Card not found or does not belong to the specified board' } });
assert.ok(!f.calls.some(([name]) => name === 'checklist' || name === 'items'));
});
}
test('access denial precedes every lookup for existing and missing checklists', async () => {
for (const checklistId of ['checklist', 'missing']) {
const f = fixture({ deny: true });
await assert.rejects(() => f.get({ checklistId }), { statusCode: 403 });
assert.deepEqual(f.calls, [['access', 'member', 'board']]);
}
});
test('an actual lookup failure propagates instead of becoming a false 404', async () => {
const error = new Error('database unavailable');
await assert.rejects(() => fixture({ lookupError: error }).get(), error);
});
+124
View File
@@ -0,0 +1,124 @@
'use strict';
// Exercise the registered integration PUT handler, including board scope and
// authorization, without starting Meteor. Live database coverage is in Playwright.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const { normalizeDeliverySettings } = require('../models/lib/notificationDelivery');
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
const route = '/api/boards/:boardId/integrations/:intId';
const plain = value => JSON.parse(JSON.stringify(value));
function fixture({ deny = false, lookupError } = {}) {
const calls = [];
const documents = [
{ _id: 'integration', boardId: 'board', title: 'Webhook', enabled: true, activities: ['all'] },
{ _id: 'foreign-integration', boardId: 'other-board', title: 'Other webhook', enabled: true },
];
const matches = (doc, selector) => Object.entries(selector).every(([key, value]) => doc[key] === value);
let handler;
const context = {
WebApp: { handlers: Object.fromEntries(['get', 'post', 'put', 'delete'].map(verb => [verb, (url, fn) => {
if (verb === 'put' && url === route) handler = fn;
}])) },
Authentication: { async checkBoardAdmin(userId, boardId) {
calls.push(['admin', userId, boardId]);
if (deny) throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
} },
ReactiveCache: { async getIntegration(selector) {
calls.push(['lookup', plain(selector)]);
if (lookupError) throw lookupError;
return documents.find(doc => matches(doc, selector));
} },
Integrations: { direct: { async updateAsync(selector, modifier) {
calls.push(['update', plain(selector), plain(modifier)]);
const doc = documents.find(item => matches(item, selector));
if (!doc) return 0;
Object.assign(doc, modifier.$set);
for (const key of Object.keys(modifier.$unset || {})) delete doc[key];
return 1;
} } },
normalizeDeliverySettings,
publicErrorData,
sendJsonResult(res, result) { res.responses.push(plain(result)); },
};
const source = fs.readFileSync(path.join(__dirname, '../server/models/integrations.js'), 'utf8');
vm.runInNewContext(source.slice(source.indexOf('WebApp.handlers.get(')), context);
assert.equal(typeof handler, 'function');
return {
calls, documents,
async put(body = {}, params = {}) {
const res = { responses: [] };
await handler({ userId: 'admin', params: { boardId: 'board', intId: 'integration', ...params }, body }, res);
assert.equal(res.responses.length, 1, 'respond exactly once');
return res.responses[0];
},
};
}
test('a valid integration updates falsy values and keeps the existing response', async () => {
const f = fixture();
assert.deepEqual(await f.put({ title: 'Updated webhook', enabled: false, activities: [] }), {
code: 200, data: { _id: 'integration' },
});
assert.deepEqual(f.documents[0], {
_id: 'integration', boardId: 'board', title: 'Updated webhook', enabled: false, activities: [],
});
});
test('empty and unchanged updates of an existing integration remain successful', async () => {
const f = fixture();
for (const body of [{}, { title: 'Webhook', enabled: true }]) {
assert.deepEqual(await f.put(body), { code: 200, data: { _id: 'integration' } });
}
});
for (const [name, params] of [
['missing integration', { intId: 'missing' }],
['integration belonging to another board', { intId: 'foreign-integration' }],
['existing integration under another board', { boardId: 'other-board' }],
]) {
test(`${name} returns 404 before any field is written or validated`, async () => {
for (const body of [{}, { title: 'Changed', enabled: false, activities: [] }, { notificationDelivery: 'invalid' }]) {
const f = fixture();
const before = plain(f.documents);
assert.deepEqual(await f.put(body, params), { code: 404, data: { error: 'Integration not found' } });
assert.deepEqual(f.calls, [
['admin', 'admin', params.boardId || 'board'],
['lookup', { _id: params.intId || 'integration', boardId: params.boardId || 'board' }],
]);
assert.deepEqual(f.documents, before);
}
});
}
test('board authorization precedes lookups for both existing and missing IDs', async () => {
for (const intId of ['integration', 'missing']) {
const f = fixture({ deny: true });
const response = await f.put({ title: 'Changed' }, { intId });
assert.equal(response.code, 403);
assert.deepEqual(f.calls, [['admin', 'admin', 'board']]);
assert.equal(f.documents[0].title, 'Webhook');
}
});
test('database errors keep their server-error response instead of becoming 404', async () => {
const f = fixture({ lookupError: new Error('database unavailable') });
const response = await f.put({ title: 'Changed' });
assert.equal(response.code, 500);
assert.equal(f.documents[0].title, 'Webhook');
assert.ok(!f.calls.some(([name]) => name === 'update'));
});
test('invalid delivery settings still reject an existing integration before writes', async () => {
const f = fixture();
const response = await f.put({ title: 'Changed', notificationDelivery: 'invalid' });
assert.equal(response.code, 400);
assert.equal(response.data.error, 'invalid-notification-delivery');
assert.equal(f.documents[0].title, 'Webhook');
assert.ok(!f.calls.some(([name]) => name === 'update'));
});
@@ -0,0 +1,77 @@
'use strict';
// Every integration route that WRITES must answer 404 for an integration that is not on the
// board in the URL (#6862 did GET; PR #6871 did PUT). The two activities routes had the same
// fault: they updated nothing and answered 200 with `data: null`. This pins the shape over the
// whole file, so a new write route cannot bring it back.
//
// Run: node tests/restIntegrationWriteRoutes404.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
const file = path.join(__dirname, '..', 'server/models/integrations.js');
const source = fs.readFileSync(file, 'utf8');
test('every handler that updates an integration looks it up on that board first', () => {
const updating = source.split(/\nWebApp\.handlers\./).slice(1)
.filter(block => /Integrations\.direct\.updateAsync/.test(block));
assert.ok(updating.length >= 3, 'PUT and both activities routes are found');
for (const block of updating) {
const update = block.indexOf('Integrations.direct.updateAsync');
const guard = block.indexOf('integrationOr404(');
assert.ok(guard > -1 && guard < update, `${block.split('\n')[0]} checks before it writes`);
// and only after board-admin authorization, so the answer tells nobody anything
assert.ok(block.indexOf('checkBoardAdmin') < guard, `${block.split('\n')[0]} authorizes first`);
}
});
test('negative: the check is not the old "write, then answer 200 with whatever is there"', () => {
assert.match(source, /async function integrationOr404\(/);
assert.match(source, /code: 404, data: \{ error: 'Integration not found' \}/);
});
function run(handlerUrl, verb, integrations) {
const calls = [];
let handler;
const context = {
WebApp: { handlers: Object.fromEntries(['get', 'post', 'put', 'delete'].map(v => [v, (url, fn) => {
if (v === verb && url === handlerUrl) handler = fn;
}])) },
Authentication: { async checkBoardAdmin() {} },
ReactiveCache: { async getIntegration(selector) {
return integrations.find(i => i._id === selector._id && i.boardId === selector.boardId);
} },
Integrations: { direct: { async updateAsync(...args) { calls.push(args); return 1; } } },
normalizeDeliverySettings: x => x,
publicErrorData,
sendJsonResult(res, result) { res.responses.push(JSON.parse(JSON.stringify(result))); },
};
vm.runInNewContext(source.slice(source.indexOf('WebApp.handlers.get(')), context);
return {
calls,
async call(params, body) {
const res = { responses: [] };
await handler({ userId: 'u', params, body }, res);
return res.responses[0];
},
};
}
for (const verb of ['post', 'delete']) {
test(`${verb.toUpperCase()} .../activities answers 404 and writes nothing for a missing or foreign integration`, async () => {
const url = '/api/boards/:boardId/integrations/:intId/activities';
const r = run(url, verb, [{ _id: 'i1', boardId: 'b1' }, { _id: 'i2', boardId: 'b2' }]);
assert.deepEqual(await r.call({ boardId: 'b1', intId: 'nope' }, { activities: ['a'] }), { code: 404, data: { error: 'Integration not found' } });
assert.deepEqual(await r.call({ boardId: 'b1', intId: 'i2' }, { activities: ['a'] }), { code: 404, data: { error: 'Integration not found' } });
assert.equal(r.calls.length, 0, 'no write happened');
// positive: its own integration is still updated
const ok = await r.call({ boardId: 'b1', intId: 'i1' }, { activities: ['a'] });
assert.equal(ok.code, 200);
assert.equal(r.calls.length, 1);
});
}
+54
View File
@@ -0,0 +1,54 @@
'use strict';
// docs/Databases/FerretDB/1/VersionHistory: what Dolt and TerminusDB would give
// WeKan's undo and redo. The scripts need a database server, so this checks what
// can be checked without one: they parse, they ask the questions the README
// answers, they leave nothing behind, and the README records the findings.
//
// Run: node tests/versionHistoryExperiment.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const DIR = path.join(__dirname, '..', 'docs/Databases/FerretDB/1/VersionHistory');
const read = f => fs.readFileSync(path.join(DIR, f), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
console.log('versionHistoryExperiment:');
test('both scripts parse', () => {
for (const f of ['dolt-history.cjs', 'terminusdb-history.cjs']) execFileSync(process.execPath, ['--check', path.join(DIR, f)]);
});
test('the Dolt script exercises undo, redo, a selective restore, time travel and the cost', () => {
const src = read('dolt-history.cjs');
for (const needle of ["DOLT_REVERT('HEAD')", 'dolt_diff_', 'AS OF', 'DOLT_RESET', '--timing', 'dolt_transaction_commit']) {
assert.ok(src.includes(needle), needle);
}
// It goes through FerretDB (the MongoDB protocol), the way WeKan writes.
assert.ok(src.includes("require('mongodb')"));
// Negative: it works in its own database and never touches WeKan's.
assert.ok(src.includes("const DB = 'wekanhistory'"));
assert.ok(!/dropDatabase\(['"](?!wekanhistory)/.test(src));
});
test('the TerminusDB script exercises the same questions through its REST API', () => {
const src = read('terminusdb-history.cjs');
for (const needle of ['/api/log/', '/api/diff/', '/api/patch/', '/local/commit/', 'restore']) {
assert.ok(src.includes(needle), needle);
}
assert.ok(src.includes("const DB = 'wekanhistory'"));
});
test('the README records what was found, including what does not work', () => {
const md = read('README.md');
for (const needle of ['DOLT_REVERT', 'are in conflict', 'Transaction commit', '+0.4 ms', 'SwapValue', 'No.** It has neither the MongoDB protocol nor SQL']) {
assert.ok(md.includes(needle), needle);
}
// The recommendation: WeKan's own History stays.
assert.match(md, /WeKan's own History is still the right undo/);
});
console.log(`versionHistoryExperiment: ${passed} tests passed`);
+94
View File
@@ -0,0 +1,94 @@
'use strict';
// The opened card, and every popup, move under a finger as well as a mouse. The card
// was moved by mousedown / mousemove / mouseup only; a finger sends none of them while
// it moves, so on an iPad the page behind scrolled and the card stayed where it was -
// from the collapse caret, the space beside it, the card number, and from the drag
// handle too. Pointer Events carry both, and the drag areas need touch-action: none.
// models/lib/windowDrag.js is the decisions; the wiring is read from the source.
// tests/playwright/specs/card-window-touch-drag.e2e.js drags with a real touch.
//
// Run: node tests/windowDragTouch.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('../models/lib/windowDrag');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const none = { onEditable: false, onTitleEditZone: false, onHandle: false, cardHasHandle: false, onControl: false };
test('with drag handles off, the header row moves the window - and a control waits for a real drag', () => {
// empty space of the row, beside the caret
assert.deepEqual(cardHeaderPress({ ...none }), { start: true, deferred: false });
// the collapse caret, the menu, close: a tap still does its job, a drag moves the window
assert.deepEqual(cardHeaderPress({ ...none, onControl: true }), { start: true, deferred: true });
});
test('the title edits, and an open editor keeps its text selection (negative)', () => {
assert.deepEqual(cardHeaderPress({ ...none, onTitleEditZone: true }), { start: false, deferred: false });
assert.deepEqual(cardHeaderPress({ ...none, onEditable: true }), { start: false, deferred: false });
assert.deepEqual(cardHeaderPress({ ...none, onEditable: true, onControl: true }), { start: false, deferred: false });
});
test('with drag handles on, only the handle moves the window', () => {
assert.deepEqual(cardHeaderPress({ ...none, onHandle: true, cardHasHandle: true }), { start: true, deferred: false });
// negative: the header row does not
assert.deepEqual(cardHeaderPress({ ...none, cardHasHandle: true }), { start: false, deferred: false });
assert.deepEqual(cardHeaderPress({ ...none, cardHasHandle: true, onControl: true }), { start: false, deferred: false });
});
test('a finger may wobble more than a mouse before a press on a control becomes a drag', () => {
assert.equal(dragThreshold('touch', false), 0, 'a press that is not on a control moves at once');
assert.equal(dragThreshold('mouse', true), 4);
assert.equal(dragThreshold('touch', true), 8);
assert.equal(dragThreshold('pen', true), 8);
assert.equal(passedThreshold(3, 3, 8), false, 'a 4 px wobble is a tap');
assert.equal(passedThreshold(6, 6, 8), true);
assert.equal(passedThreshold(0, 0, 0), true, 'threshold 0: any move');
});
test('the window cannot be dragged out of reach', () => {
const viewport = { width: 1000, height: 800 };
assert.deepEqual(clampWindow(100, 50, 520, viewport), { left: 100, top: 50 }, 'inside: unchanged');
assert.deepEqual(clampWindow(-2000, -300, 520, viewport), { left: 48 - 520, top: 0 }, 'left of the window: its right strip stays');
assert.deepEqual(clampWindow(5000, 5000, 520, viewport), { left: 1000 - 48, top: 800 - 48 }, 'right and below: its top-left strip stays');
// a viewport narrower than the strip does not produce NaN
const tiny = clampWindow(10, 10, 300, { width: 20, height: 20 });
assert.ok(Number.isFinite(tiny.left) && Number.isFinite(tiny.top));
});
test('the card window is moved by Pointer Events, and no mouse-only drag is left', () => {
const js = read('client/components/cards/cardDetails.js');
assert.match(js, /'pointerdown \.js-card-drag-handle'\(event\)/);
assert.match(js, /'pointerdown \.js-card-title-drag-handle'\(event\)/);
assert.match(js, /document\.addEventListener\('pointermove', onMove, \{ passive: false \}\)/);
assert.match(js, /document\.addEventListener\('pointercancel', onEnd\)/);
assert.match(js, /require\('\/models\/lib\/windowDrag'\)/);
// negative: the two mouse-only drag handlers are gone, and no window drag listens for mousemove
assert.doesNotMatch(js, /'mousedown \.js-card-drag-handle'/);
assert.doesNotMatch(js, /'mousedown \.js-card-title-drag-handle'/);
const helper = js.slice(js.indexOf('function beginCardWindowDrag'), js.indexOf('Template.cardDetails.events({'));
assert.doesNotMatch(helper, /mousemove|mouseup|mousedown/);
// a drag that began on a control swallows the click that follows it
assert.match(helper, /addEventListener\('click', swallow, \{ capture: true, once: true \}\)/);
// the window is clamped into reach, and the user-moved mark is kept (#6465)
assert.match(helper, /clampWindow\(/);
assert.match(helper, /markCardDetailsUserMoved\(\$card\)/);
});
test('the drag areas are touch-action: none, the card number is over the title edit zone, popups too', () => {
const css = read('client/components/cards/cardDetails.css');
assert.match(css, /body\.desktop-mode \.card-details \.card-details-header,\nbody\.desktop-mode \.card-details \.card-details-header \.card-drag-handle \{\n touch-action: none;/);
// the number above the zone (z-index 1)
assert.match(css, /\.card-details-title-edit-zone \{[^}]*z-index: 1;/);
assert.match(css, /\.card-details-header \.card-number \{[^}]*position: relative;\n z-index: 2;/);
// negative: mobile mode (the full-screen card, which is not moved) is not made unscrollable
assert.doesNotMatch(css, /body\.mobile-mode \.card-details[^{]*\{[^}]*touch-action: none/);
const popup = read('client/components/main/popup.css');
assert.match(popup, /\.pop-over \.header \{\n \/\*[\s\S]*?\*\/\n touch-action: none;/);
assert.match(popup, /\.pop-over \.header \.popup-drag-handle \{\n touch-action: none;/);
// popup.js moves a popup with Pointer Events already
assert.match(read('client/components/main/popup.js'), /'pointerdown \.header'\(evt, tpl\)/);
});