mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 12:45:03 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab29f74f6a | ||
|
|
48d8dc9de8 | ||
|
|
983aba8488 | ||
|
|
988e2f0794 | ||
|
|
90a41dbb22 | ||
|
|
8da91adaa7 | ||
|
|
80830cd43e | ||
|
|
f2edde0651 | ||
|
|
ada480c93a | ||
|
|
114d6c0599 |
No files matched your search
+190
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v10.74.0",
|
||||
"version": "v10.76.0",
|
||||
"description": "Open-Source kanban",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
@@ -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="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
" 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="<p>The REST API allows you to control and extend Wekan with ease.</p>
|
||||
<p>If you are an end-user and not a dev or a tester, <a href="https://github.com/wekan/wekan/issues/new">create an issue</a> to request new APIs.</p>
|
||||
<blockquote>
|
||||
<p>All API calls in the documentation are made using <code>curl</code>. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.</p>
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 } });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
Reference in new issue
Block a user