Compare commits

...
10 Commits
Author SHA1 Message Date
github-actions[bot] ab29f74f6a Bump versions for v10.76 2026-08-09 02:36:55 +00:00
Lauri Ojansivu 48d8dc9de8 CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.

Thanks to xet7 !
2026-08-09 05:35:11 +03:00
Lauri Ojansivu 983aba8488 Prepare v10.76 release 2026-08-09 05:35:10 +03:00
Lauri Ojansivu 988e2f0794 CHANGELOG: the three round-trip fixes, under a new Upcoming section.
v10.75 was cut while they were being written, so they get their own Upcoming
above the release. The FerretDB one is summarised here and told in full in that
repo's CHANGELOG, since it is the database that changed.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
Lauri Ojansivu 90a41dbb22 Two queries per request instead of two per card.
Neither of these gave a wrong answer. They asked the right question one document
at a time, and each await is a full round-trip that starts only once the last
one finished - invisible on a board with four cards, and the whole cost of the
request on a real one.

The bulk label endpoint (PUT .../cards/labels) awaited a getCard per id and then
an update per id. At BULK_CARDS_MAX = 500 that is ONE THOUSAND sequential
round-trips for a single request. The reads are all the same question, so they
are one `$in` query indexed into a Map; the writes genuinely differ - each card
merges its own labelIds - so they stay individual updates, but they are issued
together and awaited once instead of each waiting for the last.

Two details that had to survive: the loop still iterates cardIds rather than the
query result, because a batch read comes back in the database's order and
without the ids that matched nothing - iterating it would silently reorder
`updated` and lose `notFound`. And every write is awaited before the 200 is
sent, so the response still means what it says.

The bulk DELETE beside it is deliberately left alone: its per-card work is real
(cardRemover runs the sub-item hooks, each card gets its own activity), so
batching the reads would save one query inside a loop that does far more than
query.

Global search resolved each `user:`/`member:`/`assignee:`/`creator:` name with
its own awaited findOne, so `member:ann member:bob member:carol` was three
serial round-trips before the search itself could start. They are all the same
question - which of these names is an account - so it is asked once, with `$in`,
and answered from a map. A name typed under two operators is one lookup now, not
two. Unknown names are still reported against the operator they were typed
under, because "ann is not a user" is not useful without saying where ann was
typed.

tests/queryBatching.test.cjs pins both, including the negatives: no per-card
read remains in the label handler, no per-username query remains in search, the
response order and notFound list are preserved, and the delete handler is still
per-card on purpose.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
github-actions[bot] 8da91adaa7 Bump versions for v10.75 2026-08-09 01:48:39 +00:00
Lauri Ojansivu 80830cd43e Prepare v10.75 release 2026-08-09 04:46:10 +03:00
Lauri Ojansivu f2edde0651 CHANGELOG: a new Upcoming section for the two commits after v10.74.
The release script renamed the old Upcoming to v10.74, and two commits landed
after it: the linked-card cache that could not see a card added later, and the
build-script parity fix. They get a fresh Upcoming section above the release,
which is where development entries go.

Both were rebased onto the version bump, so their hashes changed; the links
point at the ones that will be pushed, not the ones that will be collected.

Thanks to xet7 !
2026-08-09 04:44:59 +03:00
Lauri Ojansivu ada480c93a Linked-card ids must not be cached for the life of a subscription.
The helper the five linked-card cursors share memoized its result per board, to
spare them the duplicate queries the five copies of that preamble used to make.
That is wrong: publishComposite re-runs a child's find() when the parent
document changes, and a cache living for the whole subscription then serves the
ids computed the FIRST time forever - a linked card added later would never be
published, and one removed would go on being published.

The five cursors each ran these same queries before the helper existed, so
computing per call is exactly the cost they always had, and it is correct. The
guard that pinned the memoization now pins its absence, with the reason.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
Lauri Ojansivu 114d6c0599 Build script parity: git pull and git push, on Windows too.
The one red suite in the test run, and it was right twice over.

`Setup: "git pull" and "git push" that finish the job` added two entries to
build.sh's menu and left build.bat behind, so `build.bat git-pull` answered
nothing. It answers to both names now, with its own :gitpull/:gitpush labels -
the ones its interactive menu already used.

The guard also had to learn what a `!` entry is. It marks something build.sh
runs ITSELF rather than by executing a file in releases/. Most are raw commands
and the .bat runs those the same way, so those still compare as before; the
exception is an entry naming a build.sh FUNCTION, which is shell the .bat has no
way to call. Comparing those as scripts is what made this suite fail the moment
"Update git" was replaced. A function entry is now exempt from the script
comparison and checked separately: build.bat must implement a label of the same
name, so one dropping out of a menu still fails.

The node suites are 339 run, 0 failed for the first time in this branch.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
17 changed files with 472 additions and 48 deletions

No files matched your search

+190
View File
@@ -264,6 +264,196 @@ browser build to verify).
</details>
# v10.76 2026-08-09 WeKan ® release
**In short:** three round-trip counts that were linear in the input, and are not
any more. The **bulk label** endpoint asked the database twice per card - a
thousand sequential round-trips at its 500-card cap - **global search** resolved
each named user with its own lookup before the search could start, and
**FerretDB** skipped every top-level `$or` when building a WHERE clause, so the
board-list query narrowed nothing in SQL and filtered every row in Go. None of
them was a wrong answer; each was the right question asked one document at a
time. The binaries below are v10.75's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release makes the following faster:
**The REST API** - how many times one request talks to the database.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/90a41dbb2">Bulk label changes read every card in one query, and write them together</a>. Thanks to xet7.</summary>
`PUT .../cards/labels` awaited a `getCard` per id and then an update per id. At
the `BULK_CARDS_MAX` of 500 that is **one thousand sequential round-trips** for
a single request, each starting only once the last one finished.
The reads are all the same question, so they are one `$in` query indexed into a
Map. The writes genuinely differ - each card merges its own `labelIds` - so they
stay individual updates, but they are issued together and awaited once instead
of each waiting for the last.
Two details had to survive. The loop still iterates the caller's `cardIds`
rather than the query result, because a batch read comes back in the database's
order and without the ids that matched nothing: iterating it would silently
reorder `updated` and lose `notFound`. And every write is awaited before the 200
is sent, so the response still means what it says.
The bulk DELETE beside it is deliberately left alone. Its per-card work is real
- `cardRemover` runs the sub-item hooks and each card gets its own activity - so
batching the reads would save one query inside a loop that does far more than
query.
</details>
**Global search** - what happens before the search itself starts.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/90a41dbb2">Every username a query names is resolved in one lookup</a>. Thanks to xet7.</summary>
Each `user:`, `member:`, `assignee:` and `creator:` predicate resolved its name
with its own awaited `findOne`, so `member:ann member:bob member:carol` was
three serial round-trips before the search could begin.
They are all the same question - which of these names is an account - so it is
asked once, with `$in`, and answered from a map. A name typed under two
operators is one lookup now rather than two.
An unknown name is still reported against the operator it was typed under: "ann
is not a user" is not useful without saying where ann was typed.
</details>
**The database** - what SQLite is asked, and what is filtered afterwards.
<details>
<summary><a href="https://github.com/wekan/FerretDB/commit/dab729eb">FerretDB pushes a top-level $or down to SQL when every branch can be</a>. Thanks to xet7.</summary>
FerretDB skipped every top-level `$`-key when building its WHERE clause, so a
selector whose only SELECTIVE terms sit inside an `$or` produced a clause that
narrowed nothing: SQLite returned the rows, and every one was decoded and
filtered in Go to return a handful.
That is the shape of WeKan's "which boards may this user see" query, and the
worst possible one for it - `archived = false` and `type = 'board'` push down
and match nearly everything, while the membership clauses that actually select
stayed in Go. On an instance with ten thousand boards where a user belongs to
five, that decoded ten thousand documents to return five, on every All Boards
load.
It is all or nothing, and that is the whole subtlety. Every other pushdown
NARROWS: a condition that cannot be expressed is dropped, the WHERE returns a
superset, and the Go filter removes the rest. An OR that drops a branch REMOVES
rows that match it, and the Go filter never sees them. So one unpushable branch
refuses the whole `$or`, as does a nested-operator branch and an empty one.
See [the FerretDB CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md)
for the database side.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.75 2026-08-09 WeKan ® release
**In short:** two things the full test run turned up, one in WeKan and one in
the scripts that run it. A **linked-card cache** that could not see a card
added after the first time a subscription asked, and the **build script parity**
guard that was the one red suite - right twice over, because `build.bat` really
was missing the two entries and the guard really could not tell a shell function
from a script. The binaries below are v10.74's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release fixes the following bug:
**The board publication** - what a subscriber is sent, and when.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ada480c93">Linked-card ids are no longer cached for the life of a subscription</a>. Thanks to xet7.</summary>
The helper the five linked-card cursors share memoized its result per board, to
spare them the duplicate queries the five copies of that preamble used to make.
That is wrong: `publishComposite` re-runs a child's `find()` when the parent
document changes, and a cache living for the whole subscription then serves the
ids computed the FIRST time forever - a linked card added later would never be
published, and one removed would go on being published.
The five cursors each ran these same queries before the helper existed, so
computing per call is exactly the cost they always had, and it is correct. The
guard that pinned the memoization now pins its absence, with the reason, so the
next reader does not put it back.
</details>
and has the following developer-facing change:
**The build scripts** - what each menu offers, and what the guard between them
compares.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/114d6c059">git pull and git push work on Windows too, and the parity guard knows what a function entry is</a>. Thanks to xet7.</summary>
The one red suite in the full test run, and it was right twice over.
The commit that replaced `Update git` with a `git pull` and a `git push` that
finish the job added both to `build.sh`'s menu and left `build.bat` behind, so
`build.bat git-pull` answered nothing. It answers to both names now, with its
own `:gitpull` / `:gitpush` labels - the ones its interactive menu already used.
The guard also had to learn what a `!` entry is. It marks something `build.sh`
runs ITSELF rather than by executing a file in `releases/`. Most are raw
commands and the `.bat` runs those the same way, so those still compare as
before; the exception is an entry naming a `build.sh` FUNCTION, which is shell
the `.bat` has no way to call. Comparing those as scripts is what made the suite
fail the moment `Update git` was replaced. A function entry is exempt from the
script comparison now and checked separately: `build.bat` must implement a label
of the same name, so one dropping out of a menu still fails.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.74 2026-08-09 WeKan ® release
**In short:** four security reports from **Alpastx**, all of them the same
+1 -1
View File
@@ -12,7 +12,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=10.74
ARG VERSION=10.76
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v10.74.0"
appVersion: "v10.76.0"
files:
userUploads:
- README.md
+5
View File
@@ -676,6 +676,11 @@ if /I "%K%"=="push-copy-en-gb-translation" (set "CMD=bash releases/translations/
if /I "%K%"=="report-english-regressions" (set "CMD=node releases/translations/report-english-regressions.mjs" ^& goto cli_go)
if /I "%K%"=="verify-human-preference" (set "CMD=node releases/translations/verify-human-preference.mjs" ^& goto cli_go)
if /I "%K%"=="merge-translations" (set "CMD=node releases/translations/merge-translations.mjs" ^& goto cli_go)
REM `git pull` and `git push` are shell functions in build.sh, not scripts in
REM releases/, so this answers to the same two names with its own labels - the
REM ones the interactive menu already uses.
if /I "%K%"=="git-pull" (goto gitpull)
if /I "%K%"=="git-push" (goto gitpush)
if /I "%K%"=="commit" (set "CMD=bash releases/commit.sh" ^& goto cli_go)
if /I "%K%"=="git-add-revert" (set "SHCMD=git restore --staged" ^& goto cli_go)
if /I "%K%"=="delete-branch-local-and-remote" (set "CMD=bash releases/delete-branch-local-and-remote.sh" ^& goto cli_go)
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-10.74-win64.zip](https://github.com/wekan/wekan/releases/download/v10.74/wekan-10.74-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-10.76-win64.zip](https://github.com/wekan/wekan/releases/download/v10.76/wekan-10.76-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.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-10.74-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-10.76-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v10.74.0",
"version": "v10.76.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v10.74.0",
"version": "v10.76.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v10.74.0",
"version": "v10.76.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v10.74</title>
<title>WeKan REST API v10.76</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.74<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.76<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -2005,7 +2005,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.74","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 `<field>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 Line truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.76","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 `<field>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 Line truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v10.74
version: v10.76
description: |
The REST API allows you to control and extend Wekan with ease.
+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 = 1074,
appVersion = 1076,
# Increment this for every release.
appMarketingVersion = (defaultText = "10.74~2026-08-09"),
appMarketingVersion = (defaultText = "10.76~2026-08-09"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+26 -4
View File
@@ -1612,23 +1612,45 @@ WebApp.handlers.post('/api/boards/:boardId/cards/labels', async function(req, re
return;
}
// ONE read for every card, not one per card.
//
// This used to await a getCard per id and then an update per id - at the
// BULK_CARDS_MAX of 500 that is a thousand round-trips, issued one at a time,
// for a single request. The reads are all the same question, so they are one
// `$in` query; the writes genuinely differ (each card merges its own labels),
// so they stay individual, but they are issued TOGETHER instead of each
// waiting for the last.
const cards = await ReactiveCache.getCards(
{ _id: { $in: cardIds }, boardId: paramBoardId, archived: false },
{ fields: { _id: 1, labelIds: 1 } },
);
const cardById = new Map((cards || []).map(card => [card._id, card]));
const updated = [];
const notFound = [];
const writes = [];
// Iterate cardIds, not the query result: the response has to preserve the
// caller's order and report the ids that matched nothing.
for (const cardId of cardIds) {
const card = await ReactiveCache.getCard({ _id: cardId, boardId: paramBoardId, archived: false });
const card = cardById.get(cardId);
if (!card) {
notFound.push(cardId);
continue;
}
// Merge: keep existing minus removed, then add new ones, de-duplicated.
const merged = mergeLabelIds(card.labelIds, addLabelIds, removeLabelIds);
await Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
writes.push(
Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
),
);
updated.push({ _id: cardId, labelIds: merged });
}
// Every write must land before the 200 says they did.
await Promise.all(writes);
sendJsonResult(res, { code: 200, data: { updated, notFound } });
});
+8 -4
View File
@@ -686,10 +686,15 @@ publishComposite('board', async function(boardId, isArchived) {
//
// The five cursors used to repeat this preamble verbatim; sharing it is also
// what stops the next one from being written without the check.
const _linkedIdsByBoard = new Map();
const visibleLinkedCardIds = async board => {
if (_linkedIdsByBoard.has(board._id)) return _linkedIdsByBoard.get(board._id);
// NOT memoized. An earlier version cached the result per board to spare the
// five cursors their duplicate queries - but publishComposite re-runs a
// child's find() when the parent document changes, and a cache that lives
// for the whole subscription would then serve the ids computed the FIRST
// time forever: a linked card added later would never be published, and one
// removed would go on being published. The five cursors each ran these same
// queries before this helper existed, so computing per call is exactly the
// cost they always had, and it is correct.
const compute = (async () => {
const cardSelector = {
...boardCardScope(board),
@@ -723,7 +728,6 @@ publishComposite('board', async function(boardId, isArchived) {
return (linked || []).filter(c => allowedBoardIds.has(c.boardId)).map(c => c._id);
})();
_linkedIdsByBoard.set(board._id, compute);
return compute;
};
+38 -18
View File
@@ -638,16 +638,44 @@ async function buildSelector(queryParams, userId) {
queryUsers[OPERATOR_MEMBER] = [];
queryUsers[OPERATOR_CREATOR] = [];
if (queryParams.hasOperator(OPERATOR_USER)) {
const users = [];
for (const username of queryParams.getPredicates(OPERATOR_USER)) {
const user = await ReactiveCache.getUser({ username });
if (user) {
users.push(user._id);
} else {
errors.addNotFound(OPERATOR_USER, username);
}
// Resolve every username the query names in ONE lookup.
//
// Each `user:`/`member:`/`assignee:`/`creator:` predicate used to do its own
// awaited findOne, so `member:ann member:bob member:carol` was three serial
// round-trips before the search itself could start. They are all the same
// question - which of these names is an account - so it is asked once, with
// `$in`, and answered from a map. Names that matched nothing are still
// reported per operator, exactly as before, because the operator is what
// tells the user WHERE the unknown name was typed.
const namedUsernames = new Set();
for (const key of [OPERATOR_USER, OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR]) {
if (queryParams.hasOperator(key)) {
for (const username of queryParams.getPredicates(key)) namedUsernames.add(username);
}
}
const userIdByUsername = new Map();
if (namedUsernames.size) {
const found = await ReactiveCache.getUsers(
{ username: { $in: [...namedUsernames] } },
{ fields: { _id: 1, username: 1 } },
);
(found || []).forEach(user => userIdByUsername.set(user.username, user._id));
}
// The ids a predicate names, and the names it got wrong.
const resolvePredicates = key => {
const ids = [];
for (const username of queryParams.getPredicates(key)) {
const id = userIdByUsername.get(username);
if (id) ids.push(id);
else errors.addNotFound(key, username);
}
return ids;
};
if (queryParams.hasOperator(OPERATOR_USER)) {
const users = resolvePredicates(OPERATOR_USER);
if (users.length) {
selector.$and.push({
$or: [{ members: { $in: users } }, { assignees: { $in: users } }],
@@ -657,15 +685,7 @@ async function buildSelector(queryParams, userId) {
for (const key of [OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR]) {
if (queryParams.hasOperator(key)) {
const users = [];
for (const username of queryParams.getPredicates(key)) {
const user = await ReactiveCache.getUser({ username });
if (user) {
users.push(user._id);
} else {
errors.addNotFound(key, username);
}
}
const users = resolvePredicates(key);
if (users.length) {
selector[key] = { $in: users };
}
+4 -4
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '10.74'
version: '10.76'
base: core24
confinement: strict
grade: stable
@@ -353,18 +353,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-10.74-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-10.76-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v10.74/wekan-10.74-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v10.76/wekan-10.76-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/v10.74/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v10.76/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
+24 -1
View File
@@ -237,7 +237,30 @@ test('the Releases menu is the same list, in the same order, in both scripts', (
// Same entries, same order, in the .bat's dispatch lines - EXCEPT the ones
// marked linux: systemd, ufw, snap and multipass are things Windows cannot do
// at all, so offering them there would be a menu entry that can only fail.
const forWindows = entries.filter(e => e.platform !== 'linux');
// A `!`-prefixed entry is a build.sh FUNCTION, not a script in releases/, so
// there is nothing for the .bat to `call :rel_run`. Windows implements those
// natively instead - `git pull` and `git push` are entries 3 and 4 of its main
// menu, going to :gitpull and :gitpush - and the assertion below checks that
// it really does, rather than letting a function entry silently drop out of
// one menu. Comparing them as scripts is what made this guard fail the moment
// "Update git" was replaced by the two that finish the job.
// A `!` marks an entry build.sh runs itself rather than by executing a file in
// releases/. Most are raw commands, and the .bat runs those the same way -
// those still compare. The exception is an entry naming a build.sh FUNCTION,
// which is shell the .bat has no way to call: Windows implements the same
// thing natively, and the loop below checks that it does.
const isFunction = e =>
e.script.startsWith('!') &&
/^[a-z][a-z0-9_]*$/.test(e.script.slice(1)) &&
new RegExp(`^function ${e.script.slice(1)}\\(\\)`, 'm').test(sh);
const forWindows = entries.filter(e => e.platform !== 'linux' && !isFunction(e));
for (const e of entries.filter(x => isFunction(x) && x.platform !== 'linux')) {
const label = e.script.slice(1).replace(/_/g, ''); // git_pull -> gitpull
assert.ok(new RegExp(`^:${label}\\b`, 'm').test(bat),
`build.sh offers "${e.label}" as a function; build.bat must implement :${label}`);
}
const inBat = [...bat.matchAll(/call :rel_(?:run|cmd) "([^"]+)" "([^"]*)"/g)]
.map(m => ({ what: m[1], prompt: m[2] }));
assert.deepStrictEqual(inBat.map(e => e.what),
+11 -4
View File
@@ -185,10 +185,17 @@ test('the assigned-only member restriction survived the de-duplication', () => {
assert.ok(/cardSelector\.assignees = \{ \$in: \[thisUserId\] \}/.test(helper));
});
test('the helper is memoized per board, so five cursors are not five sets of queries', () => {
const helper = publication.match(/const _linkedIdsByBoard = new Map\(\);[\s\S]*?\n \};/)[0];
assert.ok(/_linkedIdsByBoard\.has\(board\._id\)/.test(helper));
assert.ok(/_linkedIdsByBoard\.set\(board\._id, compute\)/.test(helper));
test('the helper is NOT cached across a cursor re-run', () => {
// It was, briefly. publishComposite re-runs a child's find() when the parent
// document changes, so a cache living for the whole subscription serves the
// ids computed the FIRST time forever: a linked card added later would never
// be published, and one removed would go on being published. The five cursors
// each ran these queries before the helper existed, so computing per call is
// the cost they always had - and it is correct.
assert.ok(!/_linkedIdsByBoard/.test(publication),
'no subscription-lifetime cache of the linked card ids');
const helper = publication.match(/const visibleLinkedCardIds = async board => \{[\s\S]*?\n \};/)[0];
assert.ok(/NOT memoized/.test(helper), 'and the reason is written where the next reader is');
});
// ------------------------------------------- no hand-written visibility copies
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// Three round-trip counts that were linear in the input, and are not any more.
//
// None of these was a wrong answer - they were the right answer asked one
// document at a time. A query per item is invisible on a developer's board with
// four cards and is the whole cost of the request on a real one, because each
// await is a full round-trip that starts only when the last one finished.
//
// Run: node tests/queryBatching.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const cards = read('server/models/cards.js');
const search = read('server/publications/cards.js');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ───────────────────────────── the bulk label endpoint (up to 500 cards) ─────
const bulkLabels = cards.match(
/\/\/ ONE read for every card, not one per card\.[\s\S]*?await Promise\.all\(writes\);/,
);
test('the bulk label endpoint reads every card in ONE query', () => {
assert.ok(bulkLabels, 'the batched block is there');
assert.ok(
/getCards\(\s*\{ _id: \{ \$in: cardIds \}, boardId: paramBoardId, archived: false \}/.test(bulkLabels[0]),
'one $in read for the whole batch',
);
assert.ok(/new Map\(\(cards \|\| \[\]\)\.map\(card => \[card\._id, card\]\)\)/.test(bulkLabels[0]),
'and the result is indexed, not searched per id');
});
test('negative: no per-card read remains in that handler', () => {
assert.ok(!/for \(const cardId of cardIds\) \{[\s\S]{0,200}?await ReactiveCache\.getCard\(/.test(bulkLabels[0]),
'a getCard inside the loop is the thing this replaced');
});
test('the writes are issued together, not one waiting for the last', () => {
// They genuinely differ per card - each merges its own labelIds - so they
// stay individual updates. What changed is that they no longer serialize.
assert.ok(/writes\.push\(\s*Cards\.direct\.updateAsync\(/.test(bulkLabels[0]));
assert.ok(/await Promise\.all\(writes\);/.test(bulkLabels[0]),
'and every one must land before the 200 says they did');
});
test('the response still preserves the caller\'s order and reports misses', () => {
// The loop iterates cardIds, NOT the query result: a batch read comes back in
// the database's order and without the ids that matched nothing, so iterating
// it would silently reorder `updated` and lose `notFound`.
assert.ok(/for \(const cardId of cardIds\) \{/.test(bulkLabels[0]));
assert.ok(/const card = cardById\.get\(cardId\);/.test(bulkLabels[0]));
assert.ok(/if \(!card\) \{\s*\n\s*notFound\.push\(cardId\);/.test(bulkLabels[0]));
});
test('the cap that bounds the batch is still there', () => {
assert.ok(/const BULK_CARDS_MAX = \d+;/.test(cards));
const max = Number(cards.match(/const BULK_CARDS_MAX = (\d+);/)[1]);
assert.ok(max > 0 && max <= 1000, `the cap is ${max}`);
});
test('negative: the bulk DELETE is deliberately NOT batched', () => {
// Its per-card work is real - cardRemover runs the sub-item hooks and each
// card gets its own activity - so batching the reads would save one query in
// a loop that does far more than query. Left alone on purpose.
const del = cards.match(/const deleted = \[\];[\s\S]*?for \(const cardId of cardIds\) \{[\s\S]*?await cardRemover\(/);
assert.ok(del, 'the delete handler still reads per card, and that is fine');
});
// ─────────────────────────────── global search: one lookup for every name ────
const batchBlock = search.match(
/\/\/ Resolve every username the query names in ONE lookup\.[\s\S]*?const resolvePredicates = key => \{[\s\S]*?\n \};/,
);
test('global search resolves every named username in ONE query', () => {
assert.ok(batchBlock, 'the batched block is there');
assert.ok(/getUsers\(\s*\{ username: \{ \$in: \[\.\.\.namedUsernames\] \} \}/.test(batchBlock[0]),
'one $in over every name the query mentions');
assert.ok(/fields: \{ _id: 1, username: 1 \}/.test(batchBlock[0]),
'and only the two fields it needs');
});
test('the names are collected across all four operators before asking', () => {
assert.ok(/for \(const key of \[OPERATOR_USER, OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR\]\)/
.test(batchBlock[0]));
assert.ok(/new Set\(\)/.test(batchBlock[0]),
'a name typed under two operators is one lookup, not two');
});
test('negative: no per-username query remains', () => {
assert.ok(!/for \(const username of queryParams\.getPredicates\([^)]*\)\) \{\s*\n\s*const user = await ReactiveCache\.getUser\(/.test(search),
'an awaited getUser per predicate is the thing this replaced');
});
test('an unknown name is still reported against the operator it was typed under', () => {
// This is what makes it worth keeping a per-operator step at all: "ann is not
// a user" is not useful if it does not say WHERE ann was typed.
assert.ok(/errors\.addNotFound\(key, username\)/.test(batchBlock[0]));
assert.ok(/resolvePredicates\(OPERATOR_USER\)/.test(search));
['OPERATOR_MEMBER', 'OPERATOR_ASSIGNEE', 'OPERATOR_CREATOR'].forEach(op => {
assert.ok(new RegExp(`\\[${'OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR'}\\]`).test(search),
`${op} still goes through the same resolver`);
});
});
test('the selectors built from the resolved ids are unchanged', () => {
// user: matches members OR assignees; the other three are their own field.
assert.ok(/\$or: \[\{ members: \{ \$in: users \} \}, \{ assignees: \{ \$in: users \} \}\]/.test(search));
assert.ok(/selector\[key\] = \{ \$in: users \};/.test(search));
});
// ─────────────────────────────────── FerretDB: the $or that never pushed down ─
test('FerretDB pushes a top-level $or down when every branch can be', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/backends/sqlite/query.go');
if (!fs.existsSync(go)) {
console.log(' -- .tools/FerretDB not cloned; skipping the Go side');
return;
}
const src = fs.readFileSync(go, 'utf8');
assert.ok(/func pushdownOrCondition/.test(src));
assert.ok(/if k == "\$or" \{/.test(src), 'the top-level $or is no longer skipped outright');
// ALL OR NOTHING: an OR that drops a branch removes rows the Go filter never
// sees, which is the opposite of every other pushdown's superset contract.
assert.ok(/if !condOK \{\s*\n\s*return "", nil, false\s*\n\s*\}/.test(src),
'one unpushable branch must refuse the whole $or');
assert.ok(/\$and, another \$or/.test(src) || /nested operator inside a branch/.test(src),
'a nested operator branch is refused, not silently dropped');
});
test('negative: the other top-level operators still stay in Go', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/backends/sqlite/query.go');
if (!fs.existsSync(go)) return;
const src = fs.readFileSync(go, 'utf8');
const block = src.match(/if strings\.HasPrefix\(k, "\$"\) \{[\s\S]*?continue\n\t\t\}/)[0];
assert.ok(/k == "\$or"/.test(block), 'only $or learned this');
assert.ok(!/\$and|\$nor|\$not/.test(block), 'nothing else was quietly included');
assert.ok(/continue/.test(block), 'and everything else still falls through to the Go filter');
});
console.log(`\n${passed} tests passed`);