Compare commits

...

36 Commits

Author SHA1 Message Date
renovate[bot] 3bf06722b7 chore: bump up android.gradle.plugin to v9 2026-05-22 17:52:50 +00:00
steffenrapp 925c95ce88 feat(i18n): update German translation (#15011)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Localization**
  * German language completeness raised to 100%.
* Added German translations for Markdown export/copy labels and success
text, import formats (including Bear backup and Word .docx), editor
settings (auto-date-title formats, add-icon option), AI BYOK
workspace/provider-key UI and notifications, and a recording/importing
UI prompt.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15011?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-22 01:33:38 +08:00
DarkSky 3098b3b14b feat(server): bump models (#15013)
#### PR Dependency Tree


* **PR #15013** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Expanded AI capabilities with the addition of Gemini 3.5 Flash model,
providing enhanced options for AI-powered features.

* **Updates**
* Updated Claude Sonnet to the latest version for improved performance.
  * Refreshed pro models configuration with optimized selections.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15013?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-21 15:29:00 +08:00
DarkSky dd1cd77ca0 chore(server): improve migration compatibility (#15014)
#### PR Dependency Tree


* **PR #15014** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Remove orphaned legacy subscription and entitlement records during
backfill.
* Repair workspaces missing active owners by promoting eligible members
and cleaning up empty workspaces.
* Skip cloud subscription backfill when target user/workspace no longer
exists to avoid dangling data.

* **Tests**
  * Added tests verifying legacy data cleanup during backfill.
* Added tests verifying workspace ownership repair and migration
behavior.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15014?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-21 15:28:51 +08:00
Waqar Bin zafar d20dbfd6a2 feat(editor): add page emoji display toggle #14987 (#14999)
This PR adds a display toggle for Page Emoji, so users can choose
whether the add emoji option is shown in the page header when no emoji
is set.

What changed
read editor setting for display add icon option
hide emoji placeholder entry when the setting is disabled
keep existing behavior for readonly mode and for pages that already have
an emoji
Why
This implements the feature request to control Page Emoji visibility and
improves header cleanliness for users who prefer a minimal UI.

Issue
Closes #14093
<img width="1277" height="726" alt="Screenshot 2026-05-19 at 3 44 14 PM"
src="https://github.com/user-attachments/assets/caa29272-35c0-410d-bd54-2e038e4e0db2"
/>
<img width="1511" height="779" alt="Screenshot 2026-05-19 at 3 44 35 PM"
src="https://github.com/user-attachments/assets/3504136a-d34c-45cc-992b-0056b018ff92"
/>

Testing
verified in editable mode:
setting ON: add emoji placeholder is visible when page has no emoji
setting OFF: add emoji placeholder is hidden when page has no emoji
verified in readonly mode:
no emoji: nothing shown
with emoji: existing emoji is shown
verified no regression for selecting/changing/removing emoji
Screenshots
I will attach screenshots in this section.

Quick rule checks before submit

Base branch is canary.
PR title follows conventional format: type(scope): subject.
Scope editor is valid for this repo.
Include Closes #14093 in the body.
Add your screenshots before creating or right after opening the PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an editor setting to toggle whether the "add icon" option is
shown when creating new documents (default: enabled).
* **User Experience**
* When disabled, the add-icon trigger is hidden for documents that use a
placeholder icon; readonly display remains unchanged.
* **Tests**
  * Updated tests to cover the new setting and toggle behavior.
* **Localization**
* Added translations and updated i18n typings and completeness metrics.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14999?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 22:20:12 +08:00
renovate[bot] 41145961f9 chore: bump up RevenueCat/purchases-ios-spm version to from: "5.73.0" (#15008)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[RevenueCat/purchases-ios-spm](https://redirect.github.com/RevenueCat/purchases-ios-spm)
| minor | `from: "5.66.0"` → `from: "5.73.0"` |

---

### Release Notes

<details>
<summary>RevenueCat/purchases-ios-spm
(RevenueCat/purchases-ios-spm)</summary>

###
[`v5.73.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5730)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.72.0...5.73.0)

#### 5.73.0

###
[`v5.72.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5720)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.71.0...5.72.0)

#### 5.72.0

###
[`v5.71.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5710)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.70.0...5.71.0)

#### 5.71.0

###
[`v5.70.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5700)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.69.0...5.70.0)

#### 5.70.0

###
[`v5.69.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5690)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.68.0...5.69.0)

#### 5.69.0

###
[`v5.68.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5680)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.2...5.68.0)

#### 5.68.0

###
[`v5.67.2`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5672)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.1...5.67.2)

#### 5.67.2

###
[`v5.67.1`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5671)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.0...5.67.1)

#### 5.67.1

###
[`v5.67.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5670)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.66.0...5.67.0)

#### 5.67.0

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 18:41:32 +08:00
DarkSky 1f2119e273 fix: migration timeout 2026-05-20 18:39:08 +08:00
renovate[bot] 6e97aff7ba chore: bump up oxlint-tsgolint version to ^0.23.0 (#15007)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [oxlint-tsgolint](https://redirect.github.com/oxc-project/tsgolint) |
[`^0.19.0` →
`^0.23.0`](https://renovatebot.com/diffs/npm/oxlint-tsgolint/0.19.0/0.23.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/oxlint-tsgolint/0.23.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/oxlint-tsgolint/0.19.0/0.23.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/tsgolint (oxlint-tsgolint)</summary>

###
[`v0.23.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.23.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.22.1...v0.23.0)

#### What's Changed

- chore(deps): update crate-ci/typos action to v1.45.2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;915](https://redirect.github.com/oxc-project/tsgolint/pull/915)
- feat: add skill for upgrading typescript-go by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;918](https://redirect.github.com/oxc-project/tsgolint/pull/918)
- chore(deps): update pnpm to v10.33.2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;921](https://redirect.github.com/oxc-project/tsgolint/pull/921)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;922](https://redirect.github.com/oxc-project/tsgolint/pull/922)
- fix: attach tsconfig path to diagnostics by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;923](https://redirect.github.com/oxc-project/tsgolint/pull/923)
- fix(prefer-nullish-coalescing): parenthesize mixed logical fixes by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;924](https://redirect.github.com/oxc-project/tsgolint/pull/924)
- tests(return-await): cover non-async arrow functions by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;926](https://redirect.github.com/oxc-project/tsgolint/pull/926)
- chore(deps): update github.com/go-json-experiment/json digest to
[`b6187a3`](https://redirect.github.com/oxc-project/tsgolint/commit/b6187a3)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;927](https://redirect.github.com/oxc-project/tsgolint/pull/927)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;928](https://redirect.github.com/oxc-project/tsgolint/pull/928)
- chore(deps): update crate-ci/typos action to v1.46.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;929](https://redirect.github.com/oxc-project/tsgolint/pull/929)
- chore(deps): update module github.com/dlclark/regexp2 to v2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;930](https://redirect.github.com/oxc-project/tsgolint/pull/930)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;931](https://redirect.github.com/oxc-project/tsgolint/pull/931)
- chore(deps): update typescript-go digest to
[`48e2953`](https://redirect.github.com/oxc-project/tsgolint/commit/48e2953)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;933](https://redirect.github.com/oxc-project/tsgolint/pull/933)
- chore(deps): update typescript-go digest to
[`5eb880f`](https://redirect.github.com/oxc-project/tsgolint/commit/5eb880f)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;936](https://redirect.github.com/oxc-project/tsgolint/pull/936)
- fix(no-misused-promises): handle empty JSX attributes by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;938](https://redirect.github.com/oxc-project/tsgolint/pull/938)
- fix(no-unsafe-enum-comparison): flag string literal unions by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;937](https://redirect.github.com/oxc-project/tsgolint/pull/937)
- chore(deps): update typescript-go digest to
[`e1f8f97`](https://redirect.github.com/oxc-project/tsgolint/commit/e1f8f97)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;939](https://redirect.github.com/oxc-project/tsgolint/pull/939)
- chore(deps): update typescript-go digest to
[`092b34f`](https://redirect.github.com/oxc-project/tsgolint/commit/092b34f)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;940](https://redirect.github.com/oxc-project/tsgolint/pull/940)
- chore: configure typescript-go renovate schedule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;941](https://redirect.github.com/oxc-project/tsgolint/pull/941)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;945](https://redirect.github.com/oxc-project/tsgolint/pull/945)
- chore(deps): update dependency dprint-typescript to v0.96.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;947](https://redirect.github.com/oxc-project/tsgolint/pull/947)
- chore(deps): update gomod by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;946](https://redirect.github.com/oxc-project/tsgolint/pull/946)
- chore(deps): update crate-ci/typos action to v1.46.1 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;948](https://redirect.github.com/oxc-project/tsgolint/pull/948)
- fix(prefer-nullish-coalescing): emit suggestion over fix by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;951](https://redirect.github.com/oxc-project/tsgolint/pull/951)
- chore: update packageManager to pnpm 11.0.4 by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;953](https://redirect.github.com/oxc-project/tsgolint/pull/953)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;955](https://redirect.github.com/oxc-project/tsgolint/pull/955)
- fix(no-nullable-type-assertion-style): use suggestion instead of fix
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;956](https://redirect.github.com/oxc-project/tsgolint/pull/956)
- docs: Update Go version requirement to 1.26 in CONTRIBUTING.md. by
[@&#8203;connorshea](https://redirect.github.com/connorshea) in
[#&#8203;957](https://redirect.github.com/oxc-project/tsgolint/pull/957)
- fix: allow safe promise intersection members by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;959](https://redirect.github.com/oxc-project/tsgolint/pull/959)
- ci: switch security workflow to ubuntu-latest by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;962](https://redirect.github.com/oxc-project/tsgolint/pull/962)
- chore(deps): update dependency vitest to v4.1.6 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;963](https://redirect.github.com/oxc-project/tsgolint/pull/963)
- chore(deps): update module github.com/dlclark/regexp2/v2 to v2.0.3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;964](https://redirect.github.com/oxc-project/tsgolint/pull/964)
- chore(deps): update dependency dprint-markdown to v0.22.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;965](https://redirect.github.com/oxc-project/tsgolint/pull/965)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;966](https://redirect.github.com/oxc-project/tsgolint/pull/966)
- perf(no-unnecessary-type-parameters): stop counting settled candidates
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;967](https://redirect.github.com/oxc-project/tsgolint/pull/967)
- chore: add `dprint` to pnpm `allowBuilds` by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;968](https://redirect.github.com/oxc-project/tsgolint/pull/968)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.22.1...v0.23.0>

###
[`v0.22.1`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.22.1)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1)

#### What's Changed

- fix: clarify `AGENTS.md` submodule guidance by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;909](https://redirect.github.com/oxc-project/tsgolint/pull/909)
- feat(no-unsafe-enum-comparison): implement suggestion by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;910](https://redirect.github.com/oxc-project/tsgolint/pull/910)
- feat(no-unnecessary-template-expression): implement fix by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;911](https://redirect.github.com/oxc-project/tsgolint/pull/911)
- chore(deps): update dependency vitest to v4.1.5 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;912](https://redirect.github.com/oxc-project/tsgolint/pull/912)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;913](https://redirect.github.com/oxc-project/tsgolint/pull/913)
- fix(prefer-optional-chain): avoid access comparison false positive by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;914](https://redirect.github.com/oxc-project/tsgolint/pull/914)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1>

###
[`v0.22.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.22.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0)

#### What's Changed

- chore: convert renovate config to json by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;893](https://redirect.github.com/oxc-project/tsgolint/pull/893)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;895](https://redirect.github.com/oxc-project/tsgolint/pull/895)
- ci: replace OXC\_BOT\_PAT with GitHub App tokens by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;894](https://redirect.github.com/oxc-project/tsgolint/pull/894)
- ci: add security analysis workflow by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;898](https://redirect.github.com/oxc-project/tsgolint/pull/898)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;899](https://redirect.github.com/oxc-project/tsgolint/pull/899)
- chore(deps): update module github.com/dlclark/regexp2 to v1.12.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;900](https://redirect.github.com/oxc-project/tsgolint/pull/900)
- chore(deps): update dependency typescript to v6.0.3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;901](https://redirect.github.com/oxc-project/tsgolint/pull/901)
- ci: make security analysis required-check friendly by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;902](https://redirect.github.com/oxc-project/tsgolint/pull/902)
- feat(require-await): implement suggestions by
[@&#8203;younggglcy](https://redirect.github.com/younggglcy) in
[#&#8203;896](https://redirect.github.com/oxc-project/tsgolint/pull/896)
- fix: add warning for unsupported tsgolint CLI entrypoint by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;903](https://redirect.github.com/oxc-project/tsgolint/pull/903)
- fix: resolve ancestor tsconfig for excluded nearest config by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;904](https://redirect.github.com/oxc-project/tsgolint/pull/904)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;905](https://redirect.github.com/oxc-project/tsgolint/pull/905)
- fix: handle UTF-16 diagnostics by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;906](https://redirect.github.com/oxc-project/tsgolint/pull/906)
- fix(no-useless-default-assignment): make default assignment removal a
suggestion by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;907](https://redirect.github.com/oxc-project/tsgolint/pull/907)
- fix(no-unnecessary-type-arguments): preserve shadowed type arguments
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;908](https://redirect.github.com/oxc-project/tsgolint/pull/908)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0>

###
[`v0.21.1`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.21.1)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1)

##### What's Changed

- fix(no-unnecessary-condition): handle null overlap in narrowed generic
intersections by [@&#8203;camc314](https://redirect.github.com/camc314)
in
[#&#8203;891](https://redirect.github.com/oxc-project/tsgolint/pull/891)
- revert(no-unnecessary-type-arguments): drop inference reporting by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;892](https://redirect.github.com/oxc-project/tsgolint/pull/892)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1>

###
[`v0.21.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.21.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0)

##### What's Changed

- chore: migrate gen-json-schemas to TS by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;874](https://redirect.github.com/oxc-project/tsgolint/pull/874)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;879](https://redirect.github.com/oxc-project/tsgolint/pull/879)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;883](https://redirect.github.com/oxc-project/tsgolint/pull/883)
- chore(deps): update gomod by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;884](https://redirect.github.com/oxc-project/tsgolint/pull/884)
- chore(deps): update npm packages by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;885](https://redirect.github.com/oxc-project/tsgolint/pull/885)
- feat: improve `consistent-type-exports` diagnostics quality by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;880](https://redirect.github.com/oxc-project/tsgolint/pull/880)
- chore(deps): update softprops/action-gh-release action to v3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;886](https://redirect.github.com/oxc-project/tsgolint/pull/886)
- feat: enrich the `no-array-delete` diagnostic by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;881](https://redirect.github.com/oxc-project/tsgolint/pull/881)
- feat: enrich `no-duplicate-type-constituents` diagnostic by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;882](https://redirect.github.com/oxc-project/tsgolint/pull/882)
- fix(no-meaningless-void-operator): align with typescript-eslint union
handling by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;887](https://redirect.github.com/oxc-project/tsgolint/pull/887)
- chore(deps): update crate-ci/typos action to v1.45.1 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;888](https://redirect.github.com/oxc-project/tsgolint/pull/888)
- fix(no-deprecated): avoid false positive on array destructuring
bindings by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;890](https://redirect.github.com/oxc-project/tsgolint/pull/890)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0>

### [`v0.20.0`]()

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.19.0...v0.20.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 13:17:46 +08:00
renovate[bot] 276b0db625 chore: bump up eslint-plugin-oxlint version to v1.66.0 (#15006)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-oxlint](https://redirect.github.com/oxc-project/eslint-plugin-oxlint)
| [`1.64.0` →
`1.66.0`](https://renovatebot.com/diffs/npm/eslint-plugin-oxlint/1.64.0/1.66.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-oxlint/1.66.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-oxlint/1.64.0/1.66.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/eslint-plugin-oxlint
(eslint-plugin-oxlint)</summary>

###
[`v1.66.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.66.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.65.0...v1.66.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.65.0...v1.66.0)

###
[`v1.65.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.65.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.64.0...v1.65.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.64.0...v1.65.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 10:26:38 +08:00
renovate[bot] bac346f304 chore: bump up nestjs to v13.4.1 (#15002) 2026-05-20 05:51:24 +08:00
DarkSky 9f33d37add feat(core): integrate realtime features (#15003) 2026-05-20 05:48:03 +08:00
renovate[bot] 3e42bbf4fa chore: bump up apple/swift-collections version to from: "1.5.1" (#15001)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[apple/swift-collections](https://redirect.github.com/apple/swift-collections)
| patch | `from: "1.5.0"` → `from: "1.5.1"` |

---

### Release Notes

<details>
<summary>apple/swift-collections (apple/swift-collections)</summary>

###
[`v1.5.1`](https://redirect.github.com/apple/swift-collections/releases/tag/1.5.1):
Swift Collections 1.5.1

[Compare
Source](https://redirect.github.com/apple/swift-collections/compare/1.5.0...1.5.1)

This is a patch release resolving three issues uncovered since 1.5.0 was
tagged, including a source breaking regression introduced in 1.4.0,
affecting clients importing the `Collections` module.

#### What's Changed

- Import error from `HashTreeCollections`, reported by
[@&#8203;vanvoorden](https://redirect.github.com/vanvoorden) in
[#&#8203;653](https://redirect.github.com/apple/swift-collections/issues/653)
- Resolve source break in the Collections module by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;654](https://redirect.github.com/apple/swift-collections/pull/654)
- Linker error around RigidArray when using in Embedded Swift for
WebAssembly, reported by
[@&#8203;sliemeobn](https://redirect.github.com/sliemeobn) in
[#&#8203;648](https://redirect.github.com/apple/swift-collections/issues/648)
- \[BasicContainers] Don’t define LLDB formatter symbol on Wasm by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;650](https://redirect.github.com/apple/swift-collections/pull/650)
- Guard `UniqueBox.borrow` correctly by
[@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) in
[#&#8203;649](https://redirect.github.com/apple/swift-collections/pull/649)

**Full Changelog**:
<https://github.com/apple/swift-collections/compare/1.5.0...1.5.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 23:58:03 +08:00
renovate[bot] b5e5f0708a chore: bump up Lakr233/MarkdownView version to from: "3.9.1" (#14861)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[Lakr233/MarkdownView](https://redirect.github.com/Lakr233/MarkdownView)
| minor | `from: "3.8.2"` → `from: "3.9.1"` |

---

### Release Notes

<details>
<summary>Lakr233/MarkdownView (Lakr233/MarkdownView)</summary>

###
[`v3.9.1`](https://redirect.github.com/Lakr233/MarkdownView/compare/3.9.0...3.9.1)

[Compare
Source](https://redirect.github.com/Lakr233/MarkdownView/compare/3.9.0...3.9.1)

###
[`v3.9.0`](https://redirect.github.com/Lakr233/MarkdownView/compare/3.8.2...3.9.0)

[Compare
Source](https://redirect.github.com/Lakr233/MarkdownView/compare/3.8.2...3.9.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjMuOCIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 22:52:20 +08:00
Abdul Rehman f96bf3dd24 feat(i18n): expand Urdu translation (#14995)
Closes #14994
🇵🇰 Urdu Translation for Pakistani Users
## Summary

`ur.json` previously had only 31 of 2404 keys translated (~1%), leaving
most of the AFFiNE UI in English for Urdu-speaking users. This PR fills
in the remaining ~2400 keys so Pakistani / Urdu users get a fully
localized experience.

- `packages/frontend/i18n/src/resources/ur.json` — expanded from 31 →
2404 keys
- `packages/frontend/i18n/src/i18n-completenesses.json` — `ur: 2` → `ur:
100`

Existing hand-translated keys were preserved.

## Screenshots

<img width="1600" height="716" alt="image"
src="https://github.com/user-attachments/assets/1e3395b9-7cb0-44ba-a29f-a484419eb9fd"
/>
--------

<img width="1600" height="716" alt="image"
src="https://github.com/user-attachments/assets/f03cb1ac-dde8-4425-a898-c56acebe45b6"
/>



## Test plan

- [x] Switch app language to Urdu (اردو) in Settings → Appearance →
Language
- [x] Sidebar, top bar, calendar, doc list, settings panels all render
in Urdu
- [x] RTL layout flows correctly
- [x] Prettier + lint clean

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Localization**
* Urdu language support is now fully available across the application,
including translated UI text and locale-specific content.
* Users can select Urdu as their preferred language and experience
consistent translations and messaging throughout the product.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14995?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-19 22:49:22 +08:00
DarkSky c53457691d feat(server): entitlement based model (#14996)
#### PR Dependency Tree


* **PR #14996** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Admin mutations to grant/revoke commercial entitlements.
  * New Doc comment-update permission.
  * Realtime user/workspace quota-state endpoints and live-update rooms.

* **Bug Fixes**
  * More accurate readable-doc filtering and permission evaluation.

* **Refactor**
* Workspace feature management moved to entitlement-based model;
permission and quota pipelines redesigned.
  * Admin workspace UI now edits flags only (feature toggles removed).

* **Tests**
* Extensive new and updated tests for permissions, entitlements, quota,
projection, and backfills.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-19 22:48:05 +08:00
Azamat Jauysh 103ad2a810 feat(i18n): add Kazakh translation (#14981)
Closes #14975

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Kazakh (kk) language has been added — users can now choose Kazakh for
the interface, with complete localization coverage and language metadata
(name and flag) included.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14981?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-18 16:45:36 +08:00
DarkSky ef4939009f feat(editor): handle calendar view overflow in edgeless mode (#14992)
#### PR Dependency Tree


* **PR #14992** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Calendar view now supports horizontal scrolling for better navigation.

* **Bug Fixes**
* Improved mouse wheel interaction handling to prevent unintended
scrolling.

* **Style**
* Calendar layout is now more responsive and adapts better to different
screen sizes.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14992?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-18 09:32:02 +08:00
DarkSky 0f5778ac89 feat(editor): calendar view for database block (#14984)
fix #13663


#### PR Dependency Tree


* **PR #14984** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Calendar view for database blocks (month layout, entry cards,
external-source support)
  * Workspace calendar integration and new slash-menu "Calendar View"

* **Improvements**
* Create/manage database rows from calendar UI; preserve durations when
moving/resizing ranges
* Drag-and-drop, drop-preview, and hit-testing support for calendar and
docs
  * Redesigned in-menu View settings with multi-page navigation
  * Context-menu input autofocus toggle and conditional back-navigation

* **Tests**
* New unit and E2E suites covering calendar layout, interactions,
sources, and slash-menu integration
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-17 20:40:36 +08:00
DarkSky e9ef3c50c8 fix(editor): transcript note will create useless docs (#14976)
fix #13520


#### PR Dependency Tree


* **PR #14976** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Added comprehensive test coverage for markdown insertion functionality
to verify that existing document metadata remains unchanged when
importing markdown content into workspace documents.

* **Chores**
* Optimized internal markdown-to-snapshot conversion process to use a
more direct and efficient conversion approach.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14976)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 20:18:22 +08:00
renovate[bot] 661d5d3831 chore: bump up eslint-plugin-oxlint version to v1.64.0 (#14972)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-oxlint](https://redirect.github.com/oxc-project/eslint-plugin-oxlint)
| [`1.60.0` →
`1.64.0`](https://renovatebot.com/diffs/npm/eslint-plugin-oxlint/1.60.0/1.64.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-oxlint/1.64.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-oxlint/1.60.0/1.64.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/eslint-plugin-oxlint
(eslint-plugin-oxlint)</summary>

###
[`v1.64.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.64.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.63.0...v1.64.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.63.0...v1.64.0)

###
[`v1.63.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.63.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.62.0...v1.63.0)

#####    🐞 Bug Fixes

- Ignore
[@&#8203;typescript-eslint/consistent-type-imports](https://redirect.github.com/typescript-eslint/consistent-type-imports)
for vue, astro, and svelte files  -  by
[@&#8203;Sysix](https://redirect.github.com/Sysix) in
[#&#8203;710](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/issues/710)
[<samp>(e9eb2)</samp>](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/commit/e9eb236)

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.62.0...v1.63.0)

###
[`v1.62.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.62.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.61.0...v1.62.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.61.0...v1.62.0)

###
[`v1.61.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.61.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.60.0...v1.61.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.60.0...v1.61.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzkuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE3OS4zIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 03:15:18 +08:00
DarkSky 6f55548661 fix(editor): improve tests stability (#14971)
#### PR Dependency Tree


* **PR #14971** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
  * Improved shape selection reliability in edge case testing scenarios
* Enhanced rich text editor focusing logic with better synchronization
for inline editor state validation

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14971)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 03:14:52 +08:00
renovate[bot] c39fa1ff2d chore: bump up apple/swift-collections version to from: "1.5.0" (#14969)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[apple/swift-collections](https://redirect.github.com/apple/swift-collections)
| minor | `from: "1.4.1"` → `from: "1.5.0"` |

---

### Release Notes

<details>
<summary>apple/swift-collections (apple/swift-collections)</summary>

###
[`v1.5.0`](https://redirect.github.com/apple/swift-collections/releases/tag/1.5.0):
Swift Collections 1.5.0

[Compare
Source](https://redirect.github.com/apple/swift-collections/compare/1.4.1...1.5.0)

This feature release supports Swift toolchain versions 6.0, 6.1, 6.2,
and 6.3. It includes the following new features and bug fixes:

##### Debugging enhancements

The package now defines LLDB data formatters for `RigidArray`. The
formatters are emitted into the executable binary, and they are
automatically loaded by LLDB. We expect to implement formatters for
(many) more types in subsequent releases.

##### New stable APIs

- `RigidArray` and `UniqueArray` now conform to `Equatable` when their
element type is `Equatable`. This conformance requires a Swift 6.4 or
later toolchain (it relies on [SE-0499][SE-0499] generalizations of
`Equatable`/`Hashable` to support noncopyable conforming types).
- `RigidArray` and `UniqueArray` gained an `isTriviallyIdentical(to:)`
operation, which reports whether two instances share their underlying
storage allocation. This does not require the element type to be
`Equatable`, and it works with noncopyable elements.
- [`BitSet`][BitSet] gained a `makeIterator(from:)` shortcut for
starting iteration at (or after) a specific member, avoiding a linear
scan from the start of the set.
- [`OrderedDictionary`][OrderedDictionary] gained a
`replaceElement(at:withKey:value:)` operation that replaces the
key-value pair at a given index. The new key is allowed to equal the
existing key at that index (in which case only the value is updated).

[BitSet]:
https://swiftpackageindex.com/apple/swift-collections/documentation/bitcollections/bitset

[OrderedDictionary]:
https://swiftpackageindex.com/apple/swift-collections/documentation/orderedcollections/ordereddictionary

[SE-0499]:
https://redirect.github.com/swiftlang/swift-evolution/blob/main/proposals/0499-equatable-hashable-comparable-noncopyable.md

##### Experimental hashed containers (`UnstableHashedContainers` trait)

The Robin-Hood-hashed `UniqueSet`, `RigidSet`, `UniqueDictionary`, and
`RigidDictionary` types in the `BasicContainers` module continue to
evolve behind the `UnstableHashedContainers` package trait. This release
brings a number of correctness fixes and performance improvements:

- Faster removals, with better `maxProbeLength` maintenance to avoid
probe-length bloat.
- Small tables are now scrambled to avoid degenerate patterns on common
key distributions.
- A fast-path shortcut for insertions into under-utilized tables.
- Fixes to the insertion algorithm and to
`RigidDictionary.updateValue(forKey:with:)` (the latter exhibited
undefined behavior on removals).
- `RigidSet.insert(maximumCount:from:)` no longer spuriously reports a
capacity overflow due to incorrect accounting.
- The `UnstableHashedContainers` trait can now be enabled independently
of `UnstableContainersPreview`.

These types remain source-unstable for now.

##### Experimental sorted collections (`UnstableSortedCollections`
trait)

The `SortedCollections` module's [`SortedSet`][SortedSet] has gained the
following additions:

- `SortedSet` now supports value-range subscripts for the full variety
of standard range expression types, `ClosedRange`, `PartialRangeFrom`,
`PartialRangeThrough`, and `PartialRangeUpTo`.
- `SortedSet.firstIndex(after:)` and `SortedSet.lastIndex(before:)`
return the index to the nearest member following or preceding a given
value.

This release also fixes several underlying B-tree bugs that were
surfaced by these additions.

These types remain source-unstable; they have known API deficiencies
that will need to be addressed before they ship.

[SortedSet]:
https://redirect.github.com/apple/swift-collections/tree/main/Sources/SortedCollections/SortedSet

##### Experimental container protocols (`UnstableContainersPreview`
trait)

The `ContainersPreview` module's protocol hierarchy and associated types
continue to be developed. Several constructs have been renamed to follow
Swift Evolution proposals in flight.

| Old name                  | New name                    |
| ------------------------- | --------------------------- |
| `struct Box<T>`           | `struct UniqueBox<Value>`   |
| `struct Borrow<Target>`   | `struct Ref<Target>`        |
| `struct Inout<Target>`    | `struct MutableRef<Target>` |
| `Producer.ProducerError`  | `Producer.Failure`          |
| `Producer.generateNext()` | `Producer.next()`           |
| `Producer.skip(upTo:)`    | `Producer.skip(by:)`        |

For `UniqueBox`, `Ref` and `MutableRef`, there are deprecated
typealiases for the old names, preserving source compatibility.

Other changes to the experimental container model:

- `Container.Index` no longer needs to conform to `Comparable`. This
allows linked lists to become containers.
- `RigidArray`, `UniqueArray`, `RigidDeque`, and `UniqueDeque` now
conform to the container protocols.
- Added `Producer.collect(into:)` for collecting a producer's output
into a `RangeReplaceableContainer`.
- Added `BorrowingIteratorProtocol.copy()` for turning a borrowing
iterator into a producer.
- Added `filter` and `map` overloads for `BorrowingIteratorProtocol`,
`Producer`, and `Drain`.
- `BorrowingSequence.first` was removed.
- `BorrowingSequence`, `BorrowingIteratorProtocol` and their
requirements have temporarily gained trailing underscores to avoid
naming conflicts with the (provisional) protocol definition in the
Standard Library. We expect these definitions to be removed when these
protocols officially become part of the stdlib.

The protocol-based APIs in `ContainersPreview` now require a Swift 6.4
or later toolchain. `UniqueBox` is source-stable, therefore it continues
to require Swift 6.2.

##### Notable bug fixes

- `HashTreeCollections`: Fixed an invariant violation that could be
triggered by some operations on `TreeSet`/`TreeDictionary`.
- `_RopeModule`: Fixed an infinite loop when hashing the UTF-8 view of a
multi-chunk big substring.
- `BitCollections`: Fixed a bogus precondition in
`BitArray.insert(repeating:count:at:)`; fixed `BitSet.isSubset(of:
Range<Int>)` to correctly examine elements above the range's upper word.
- `HeapModule`: Fixed `Heap.insert(contentsOf:)` to use a wrapping
multiply in its Floyd-heuristic computation; added a missing bounds
assertion in `Heap._UnsafeHandle.swapAt(_:with:)`.
- `OrderedCollections`: Fixed `OrderedSet` crash on negative capacity
values; minor fixes in `_HashTable.UnsafeHandle`.
- `DequeModule`: Fixed sizing issue in
`UniqueDeque.replace(removing:addingCount:initializingWith:)`; fixed a
missing argument validation in
`RigidDeque.nextMutableSpan(after:maximumCount:)`;
`RigidDeque.consume(_:consumingWith:)` now closes the resulting gap
before returning; added zero-count fast-paths; replace/prepend
operations taking a `Collection` now verify that the source's count
matches its contents.
- `BasicContainers`: Fixed an overallocation issue in
`UniqueArray.replace(removing:copying:)`; fixed a partial-initialization
correctness issue in
`RigidArray.replace(removing:consumingWith:addingCount:initializingWith:)`.

#### What's Changed

- Add tests that build the ContainersPreview module by
[@&#8203;natecook1000](https://redirect.github.com/natecook1000) in
[#&#8203;610](https://redirect.github.com/apple/swift-collections/pull/610)
- Add a workflow that performs a CMake build by
[@&#8203;natecook1000](https://redirect.github.com/natecook1000) in
[#&#8203;612](https://redirect.github.com/apple/swift-collections/pull/612)
- Align `BorrowingSequence` implementation with proposal by
[@&#8203;natecook1000](https://redirect.github.com/natecook1000) in
[#&#8203;609](https://redirect.github.com/apple/swift-collections/pull/609)
- Bump
swiftlang/github-workflows/.github/workflows/swift\_package\_test.yml
from 0.0.8 to 0.0.9 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;615](https://redirect.github.com/apple/swift-collections/pull/615)
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from
0.0.8 to 0.0.9 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;614](https://redirect.github.com/apple/swift-collections/pull/614)
- Fix lifetime requirements rigidly enforced in the latest nightlies by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;617](https://redirect.github.com/apple/swift-collections/pull/617)
- Track array proposal by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;619](https://redirect.github.com/apple/swift-collections/pull/619)
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from
0.0.9 to 0.0.10 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;620](https://redirect.github.com/apple/swift-collections/pull/620)
- OrderedSet: Don't crash on negative capacity values by
[@&#8203;thisismanan](https://redirect.github.com/thisismanan) in
[#&#8203;622](https://redirect.github.com/apple/swift-collections/pull/622)
- \[ContainersPreview] Don’t require `Container.Index` to conform to
`Comparable` by [@&#8203;lorentey](https://redirect.github.com/lorentey)
in
[#&#8203;623](https://redirect.github.com/apple/swift-collections/pull/623)
- Adjust experimental workflows by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;626](https://redirect.github.com/apple/swift-collections/pull/626)
- [BitSet] Add `BitSet.makeIterator(from:)` by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;627](https://redirect.github.com/apple/swift-collections/pull/627)
- \[BasicContainers] RigidSet.insert(maximumCount:from:): Fix spurious
capacity overflow caused by incorrect accounting by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;628](https://redirect.github.com/apple/swift-collections/pull/628)
- \[BasicContainers]
RigidArray.replace(removing:consumingWith:addingCount:initializingWith:):
Fix correctness issue with partial initialization by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;629](https://redirect.github.com/apple/swift-collections/pull/629)
- \[BasicContainers] UniqueArray.replace(removing:copying): Fix
overallocation issue by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;630](https://redirect.github.com/apple/swift-collections/pull/630)
- Fix \_trim(first:) returning wrong buffer region by
[@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) in
[#&#8203;631](https://redirect.github.com/apple/swift-collections/pull/631)
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from
0.0.10 to 0.0.11 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;625](https://redirect.github.com/apple/swift-collections/pull/625)
- \[OrderedCollections] Add
OrderedDictionary.replaceElement(at:withKey:… by
[@&#8203;inju2403](https://redirect.github.com/inju2403) in
[#&#8203;616](https://redirect.github.com/apple/swift-collections/pull/616)
- \[ContainersPreview] Producer.ProducerError ⟹ Producer.Failure by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;634](https://redirect.github.com/apple/swift-collections/pull/634)
- fix: reserveCapacity DocC link in RigidArray by
[@&#8203;manojmahapatra](https://redirect.github.com/manojmahapatra) in
[#&#8203;633](https://redirect.github.com/apple/swift-collections/pull/633)
- \[BasicContainers, DequeModule]: Assorted fixes by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;632](https://redirect.github.com/apple/swift-collections/pull/632)
- \[Debugging] Add lldb data formatter for RigidArray by
[@&#8203;kastiglione](https://redirect.github.com/kastiglione) in
[#&#8203;607](https://redirect.github.com/apple/swift-collections/pull/607)
- \[HashTreeCollections] Fix invariant violation in
\_HashNode.\_regularNode by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;635](https://redirect.github.com/apple/swift-collections/pull/635)
- \[BitCollections] Fix small issues by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;637](https://redirect.github.com/apple/swift-collections/pull/637)
- \[HeapModule, SortedCollections] Assorted tool-assisted fixes and
adjustments by [@&#8203;lorentey](https://redirect.github.com/lorentey)
in
[#&#8203;639](https://redirect.github.com/apple/swift-collections/pull/639)
- \[BasicContainers] Enable APIs scheduled to ship in 1.5.0 by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;641](https://redirect.github.com/apple/swift-collections/pull/641)
- \[BasicContainers] Fix copypasta in `UniqueArray.edit`’s docs by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;642](https://redirect.github.com/apple/swift-collections/pull/642)
- Rename `Box` to `UniqueBox`; align API surface with SE-0517 by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;640](https://redirect.github.com/apple/swift-collections/pull/640)
- Bump
swiftlang/github-workflows/.github/workflows/swift\_package\_test.yml
from 0.0.9 to 0.0.11 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;624](https://redirect.github.com/apple/swift-collections/pull/624)
- Use the defines from traits directly by
[@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) in
[#&#8203;644](https://redirect.github.com/apple/swift-collections/pull/644)
- \[ContainersPreview] `struct Borrow` ⟹ `struct Ref` by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;643](https://redirect.github.com/apple/swift-collections/pull/643)
- \[ContainersPreview] `struct Inout` ⟹ `struct MutableRef` by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;646](https://redirect.github.com/apple/swift-collections/pull/646)
- 1.5.0 release preparations by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;647](https://redirect.github.com/apple/swift-collections/pull/647)

#### New Contributors

- [@&#8203;thisismanan](https://redirect.github.com/thisismanan) made
their first contribution in
[#&#8203;622](https://redirect.github.com/apple/swift-collections/pull/622)
- [@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) made
their first contribution in
[#&#8203;631](https://redirect.github.com/apple/swift-collections/pull/631)
- [@&#8203;inju2403](https://redirect.github.com/inju2403) made their
first contribution in
[#&#8203;616](https://redirect.github.com/apple/swift-collections/pull/616)
- [@&#8203;manojmahapatra](https://redirect.github.com/manojmahapatra)
made their first contribution in
[#&#8203;633](https://redirect.github.com/apple/swift-collections/pull/633)
- [@&#8203;kastiglione](https://redirect.github.com/kastiglione) made
their first contribution in
[#&#8203;607](https://redirect.github.com/apple/swift-collections/pull/607)

**Full Changelog**:
<https://github.com/apple/swift-collections/compare/1.4.1...1.5.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzkuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE3OS4zIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 01:02:39 +08:00
DarkSky 3416de1e4d fix(server): missing root cert (#14970)
#### PR Dependency Tree


* **PR #14970** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated TLS library dependencies with pinned version constraints
across multiple packages
* Removed `tls-rustls` feature from sqlx configurations in backend and
frontend packages
  * Removed unused `sqlx` dependency from mobile native package
* Refined HTTPS client configuration with embedded certificate roots and
added validation test

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14970)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 01:02:07 +08:00
renovate[bot] d9cebdfc95 chore: bump up nestjs (#14968)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@nestjs/common](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.20/11.1.21?slim=true)
|
| [@nestjs/core](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.20/11.1.21?slim=true)
|
| [@nestjs/platform-express](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.20/11.1.21?slim=true)
|
| [@nestjs/platform-socket.io](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.20/11.1.21?slim=true)
|
| [@nestjs/swagger](https://redirect.github.com/nestjs/swagger) |
[`11.4.2` →
`11.4.3`](https://renovatebot.com/diffs/npm/@nestjs%2fswagger/11.4.2/11.4.3)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fswagger/11.4.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fswagger/11.4.2/11.4.3?slim=true)
|
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.20/11.1.21?slim=true)
|

---

### Release Notes

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/core)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-express)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-socket.io)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/swagger (@&#8203;nestjs/swagger)</summary>

###
[`v11.4.3`](https://redirect.github.com/nestjs/swagger/compare/11.4.2...0d79a3c9dea89236314609f8b18ec98b12c18692)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.4.2...11.4.3)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/websockets)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 23:39:14 +08:00
renovate[bot] 97d9ae3183 chore: bump up @opentelemetry/semantic-conventions version to v1.41.1 (#14962)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@opentelemetry/semantic-conventions](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`1.40.0` →
`1.41.1`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsemantic-conventions/1.40.0/1.41.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fsemantic-conventions/1.41.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fsemantic-conventions/1.40.0/1.41.1?slim=true)
|

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js
(@&#8203;opentelemetry/semantic-conventions)</summary>

###
[`v1.41.1`](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/ed6bd6d5f3a1f68b65ae25b1a8aae9c285ae83de...013c60085b84351a4c1e4e4f79e3dd67c56661cd)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/ed6bd6d5f3a1f68b65ae25b1a8aae9c285ae83de...013c60085b84351a4c1e4e4f79e3dd67c56661cd)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 18:25:55 +08:00
DarkSky c8cdc488db feat(server): entitlement primitive (#14964)
#### PR Dependency Tree


* **PR #14964** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added entitlement resolution to validate licenses and derive plan,
quotas, expiry and flags.
* Introduced persistent quota/entitlement state for users and workspaces
with legacy sync behavior.
* Real-time quota-state operations and change events for monitoring
usage.

* **Chores**
  * Updated workspace dependencies to add cryptography/hash crates.

* **Tests**
* Added native entitlement tests covering validation, quantity handling,
and signature/expiry cases.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14964)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 18:25:03 +08:00
Jachin 542da0b347 feat(editor): improve latex editing support (#14924)
## Summary
- support converting selected text into inline LaTeX equations
- support turning text blocks into LaTeX equation blocks
- add equation entries to editor toolbars while keeping inline equation
with text formatting actions

## Tests
- yarn tsc -b blocksuite/affine/inlines/latex/tsconfig.json
blocksuite/affine/blocks/note/tsconfig.json
blocksuite/affine/blocks/root/tsconfig.json
blocksuite/affine/rich-text/tsconfig.json
blocksuite/affine/widgets/keyboard-toolbar/tsconfig.json --pretty false
- git diff --check origin/canary...HEAD

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Equation block support with conversion from existing blocks.
  * Inline LaTeX insertion added to the inline formatting toolbar.
* Equation action added to the keyboard toolbar; Equation blocks
searchable via math/equation/latex aliases.

* **Improvements**
* Inline LaTeX editor opens and syncs more reliably; selection/convert
flow preserves distinct LaTeX values when converting in reverse order.

* **Tests**
  * New e2e tests for inline LaTeX conversions and value preservation.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14924)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 11:56:54 +08:00
renovate[bot] 7280fe33bc chore: bump up Node.js to v22.22.3 (#14961)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [node](https://nodejs.org)
([source](https://redirect.github.com/nodejs/node)) | patch | `22.22.2`
→ `22.22.3` |

---

### Release Notes

<details>
<summary>nodejs/node (node)</summary>

###
[`v22.22.3`](https://redirect.github.com/nodejs/node/releases/tag/v22.22.3):
2026-05-13, Version 22.22.3 'Jod' (LTS), @&#8203;marco-ippolito

[Compare
Source](https://redirect.github.com/nodejs/node/compare/v22.22.2...v22.22.3)

##### Commits

-
\[[`4f780905c5`](https://redirect.github.com/nodejs/node/commit/4f780905c5)]
- **crypto**: fix potential null pointer dereference when
BIO\_meth\_new() fails (Nora Dossche)
[#&#8203;61788](https://redirect.github.com/nodejs/node/pull/61788)
-
\[[`4a09efb947`](https://redirect.github.com/nodejs/node/commit/4a09efb947)]
- **crypto**: update root certificates to NSS 3.121 (Node.js GitHub Bot)
[#&#8203;62485](https://redirect.github.com/nodejs/node/pull/62485)
-
\[[`e4c0d99839`](https://redirect.github.com/nodejs/node/commit/e4c0d99839)]
- **deps**: update timezone to 2026a (Node.js GitHub Bot)
[#&#8203;62164](https://redirect.github.com/nodejs/node/pull/62164)
-
\[[`0226c8dd7a`](https://redirect.github.com/nodejs/node/commit/0226c8dd7a)]
- **deps**: update simdjson to 4.5.0 (Node.js GitHub Bot)
[#&#8203;62382](https://redirect.github.com/nodejs/node/pull/62382)
-
\[[`e742ab748c`](https://redirect.github.com/nodejs/node/commit/e742ab748c)]
- **deps**: update sqlite to 3.51.3 (Node.js GitHub Bot)
[#&#8203;62256](https://redirect.github.com/nodejs/node/pull/62256)
-
\[[`73cac0571a`](https://redirect.github.com/nodejs/node/commit/73cac0571a)]
- **deps**: update amaro to 1.1.8 (Node.js GitHub Bot)
[#&#8203;62151](https://redirect.github.com/nodejs/node/pull/62151)
-
\[[`ae5c162b93`](https://redirect.github.com/nodejs/node/commit/ae5c162b93)]
- **deps**: update amaro to 1.1.7 (Node.js GitHub Bot)
[#&#8203;61730](https://redirect.github.com/nodejs/node/pull/61730)
-
\[[`b819cb9977`](https://redirect.github.com/nodejs/node/commit/b819cb9977)]
- **deps**: update amaro to 1.1.6 (Node.js GitHub Bot)
[#&#8203;61603](https://redirect.github.com/nodejs/node/pull/61603)
-
\[[`bbcce09dc7`](https://redirect.github.com/nodejs/node/commit/bbcce09dc7)]
- **deps**: update sqlite to 3.52.0 (Node.js GitHub Bot)
[#&#8203;62150](https://redirect.github.com/nodejs/node/pull/62150)
-
\[[`22ff2d81ce`](https://redirect.github.com/nodejs/node/commit/22ff2d81ce)]
- **deps**: update simdjson to 4.3.1 (Node.js GitHub Bot)
[#&#8203;61930](https://redirect.github.com/nodejs/node/pull/61930)
-
\[[`f49b51d75c`](https://redirect.github.com/nodejs/node/commit/f49b51d75c)]
- **deps**: update acorn-walk to 8.3.5 (Node.js GitHub Bot)
[#&#8203;61928](https://redirect.github.com/nodejs/node/pull/61928)
-
\[[`1a5cec0d49`](https://redirect.github.com/nodejs/node/commit/1a5cec0d49)]
- **deps**: update acorn to 8.16.0 (Node.js GitHub Bot)
[#&#8203;61925](https://redirect.github.com/nodejs/node/pull/61925)
-
\[[`d339497688`](https://redirect.github.com/nodejs/node/commit/d339497688)]
- **deps**: update nbytes to 0.1.3 (Node.js GitHub Bot)
[#&#8203;61879](https://redirect.github.com/nodejs/node/pull/61879)
-
\[[`3ff8ffd459`](https://redirect.github.com/nodejs/node/commit/3ff8ffd459)]
- **deps**: remove stale OpenSSL arch configs (René)
[#&#8203;61834](https://redirect.github.com/nodejs/node/pull/61834)
-
\[[`b8ddbc1e9a`](https://redirect.github.com/nodejs/node/commit/b8ddbc1e9a)]
- **deps**: update llhttp to 9.3.1 (Node.js GitHub Bot)
[#&#8203;61827](https://redirect.github.com/nodejs/node/pull/61827)
-
\[[`ffda97afd4`](https://redirect.github.com/nodejs/node/commit/ffda97afd4)]
- **deps**: update googletest to
[`2461743`](https://redirect.github.com/nodejs/node/commit/2461743991f9aa53e9a3625eafcbacd81a3c74cd)
(Node.js GitHub Bot)
[#&#8203;62484](https://redirect.github.com/nodejs/node/pull/62484)
-
\[[`79aa32cf4f`](https://redirect.github.com/nodejs/node/commit/79aa32cf4f)]
- **deps**: update googletest to
[`73a63ea`](https://redirect.github.com/nodejs/node/commit/73a63ea05dc8ca29ec1d2c1d66481dd0de1950f1)
(Node.js GitHub Bot)
[#&#8203;61927](https://redirect.github.com/nodejs/node/pull/61927)
-
\[[`b6957e13b6`](https://redirect.github.com/nodejs/node/commit/b6957e13b6)]
- **deps**: update archs files for openssl-3.5.6 (Node.js GitHub Bot)
[#&#8203;62629](https://redirect.github.com/nodejs/node/pull/62629)
-
\[[`3a27669063`](https://redirect.github.com/nodejs/node/commit/3a27669063)]
- **deps**: upgrade openssl sources to openssl-3.5.6 (Node.js GitHub
Bot) [#&#8203;62629](https://redirect.github.com/nodejs/node/pull/62629)
-
\[[`d568a1bb53`](https://redirect.github.com/nodejs/node/commit/d568a1bb53)]
- **deps**: upgrade npm to 10.9.8 (npm team)
[#&#8203;62463](https://redirect.github.com/nodejs/node/pull/62463)
-
\[[`ec11f3c1d5`](https://redirect.github.com/nodejs/node/commit/ec11f3c1d5)]
- **deps**: V8: backport
[`85b3900`](https://redirect.github.com/nodejs/node/commit/85b390089e51)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`08609712ed`](https://redirect.github.com/nodejs/node/commit/08609712ed)]
- **deps**: V8: backport
[`1b27e46`](https://redirect.github.com/nodejs/node/commit/1b27e4674f11)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`dcc60d5ab2`](https://redirect.github.com/nodejs/node/commit/dcc60d5ab2)]
- **deps**: V8: backport
[`9997fc0`](https://redirect.github.com/nodejs/node/commit/9997fc013952)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`1d1f4451fb`](https://redirect.github.com/nodejs/node/commit/1d1f4451fb)]
- **deps**: V8: cherry-pick
[`b96e40d`](https://redirect.github.com/nodejs/node/commit/b96e40d5ac85)
(Clemens Backes)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`2268567237`](https://redirect.github.com/nodejs/node/commit/2268567237)]
- **deps**: V8: cherry-pick
[`7cb6188`](https://redirect.github.com/nodejs/node/commit/7cb6188cf913)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`92804cdbea`](https://redirect.github.com/nodejs/node/commit/92804cdbea)]
- **deps**: V8: cherry-pick
[`e7ccf0a`](https://redirect.github.com/nodejs/node/commit/e7ccf0af1bdd)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`eae2c27a40`](https://redirect.github.com/nodejs/node/commit/eae2c27a40)]
- **deps**: V8: cherry-pick
[`8e214ec`](https://redirect.github.com/nodejs/node/commit/8e214ec3ec8c)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`a1799a49bb`](https://redirect.github.com/nodejs/node/commit/a1799a49bb)]
- **deps**: V8: backport
[`63b8849`](https://redirect.github.com/nodejs/node/commit/63b8849d73ae)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`a2df2d8731`](https://redirect.github.com/nodejs/node/commit/a2df2d8731)]
- **deps**: V8: backport
[`3239427`](https://redirect.github.com/nodejs/node/commit/323942700cfe)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`e3d65c7dca`](https://redirect.github.com/nodejs/node/commit/e3d65c7dca)]
- **deps**: V8: backport
[`89dc6ea`](https://redirect.github.com/nodejs/node/commit/89dc6eab605c)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`5e7db133de`](https://redirect.github.com/nodejs/node/commit/5e7db133de)]
- **deps**: V8: backport
[`910cb91`](https://redirect.github.com/nodejs/node/commit/910cb91733dc)
(Jakob Kummerow)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`d0c24a28af`](https://redirect.github.com/nodejs/node/commit/d0c24a28af)]
- **deps**: V8: cherry-pick
[`b8f91e5`](https://redirect.github.com/nodejs/node/commit/b8f91e510e0f)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`d358687824`](https://redirect.github.com/nodejs/node/commit/d358687824)]
- **deps**: V8: cherry-pick
[`cf03d55`](https://redirect.github.com/nodejs/node/commit/cf03d55db2a0)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`67c8b2c349`](https://redirect.github.com/nodejs/node/commit/67c8b2c349)]
- **deps**: V8: cherry-pick
[`692f3d5`](https://redirect.github.com/nodejs/node/commit/692f3d526a38)
(Sébastien Doeraene)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`71e5a59ffd`](https://redirect.github.com/nodejs/node/commit/71e5a59ffd)]
- **deps**: V8: cherry-pick
[`c734674`](https://redirect.github.com/nodejs/node/commit/c734674e03f9)
(Manos Koukoutos)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`f0dbe81c7b`](https://redirect.github.com/nodejs/node/commit/f0dbe81c7b)]
- **deps**: V8: cherry-pick
[`b2f3aea`](https://redirect.github.com/nodejs/node/commit/b2f3aea23a01)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`d333f480c3`](https://redirect.github.com/nodejs/node/commit/d333f480c3)]
- **deps**: V8: cherry-pick
[`5f1342c`](https://redirect.github.com/nodejs/node/commit/5f1342c20b59)
(Matthias Liedtke)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`db722725bb`](https://redirect.github.com/nodejs/node/commit/db722725bb)]
- **deps**: use npm undici\@&#8203;six tag in `update-undici.sh` (Matteo
Collina)
[#&#8203;63012](https://redirect.github.com/nodejs/node/pull/63012)
-
\[[`9b57979d9c`](https://redirect.github.com/nodejs/node/commit/9b57979d9c)]
- **doc**: add Rafael to last security release steward (Rafael Gonzaga)
[#&#8203;62423](https://redirect.github.com/nodejs/node/pull/62423)
-
\[[`d8075585bf`](https://redirect.github.com/nodejs/node/commit/d8075585bf)]
- **doc**: add path to vulnerabilities.json mention (Rafael Gonzaga)
[#&#8203;62355](https://redirect.github.com/nodejs/node/pull/62355)
-
\[[`6ec9a70204`](https://redirect.github.com/nodejs/node/commit/6ec9a70204)]
- **doc**: clarify fs.ReadStream and fs.WriteStream are not
constructable (Kit Dallege)
[#&#8203;62208](https://redirect.github.com/nodejs/node/pull/62208)
-
\[[`1fc86fcb6e`](https://redirect.github.com/nodejs/node/commit/1fc86fcb6e)]
- **doc**: add note (and caveat) for `mock.module` about customization
hooks (Jacob Smith)
[#&#8203;62075](https://redirect.github.com/nodejs/node/pull/62075)
-
\[[`491be80bd9`](https://redirect.github.com/nodejs/node/commit/491be80bd9)]
- **doc**: add efekrskl as triager (Efe)
[#&#8203;61876](https://redirect.github.com/nodejs/node/pull/61876)
-
\[[`18558293a3`](https://redirect.github.com/nodejs/node/commit/18558293a3)]
- **doc**: fix module.stripTypeScriptTypes indentation (René)
[#&#8203;61992](https://redirect.github.com/nodejs/node/pull/61992)
-
\[[`8e20976522`](https://redirect.github.com/nodejs/node/commit/8e20976522)]
- **doc**: explicitly mention Slack handle (Rafael Gonzaga)
[#&#8203;61986](https://redirect.github.com/nodejs/node/pull/61986)
-
\[[`70b8e6b4fb`](https://redirect.github.com/nodejs/node/commit/70b8e6b4fb)]
- **doc**: rename invalid `function` parameter (René)
[#&#8203;61942](https://redirect.github.com/nodejs/node/pull/61942)
-
\[[`4045c76f6c`](https://redirect.github.com/nodejs/node/commit/4045c76f6c)]
- **doc**: clarify status of feature request issues (Antoine du Hamel)
[#&#8203;61505](https://redirect.github.com/nodejs/node/pull/61505)
-
\[[`c54652f2aa`](https://redirect.github.com/nodejs/node/commit/c54652f2aa)]
- **doc**: remove incorrect mention of `module` in `typescript.md` (Rob
Palmer)
[#&#8203;61839](https://redirect.github.com/nodejs/node/pull/61839)
-
\[[`9fad6cedf5`](https://redirect.github.com/nodejs/node/commit/9fad6cedf5)]
- **doc**: clarify async caveats for `events.once()` (René)
[#&#8203;61572](https://redirect.github.com/nodejs/node/pull/61572)
-
\[[`2f1e5733fe`](https://redirect.github.com/nodejs/node/commit/2f1e5733fe)]
- **doc**: update Juan's security steward info (Juan José)
[#&#8203;61754](https://redirect.github.com/nodejs/node/pull/61754)
-
\[[`a64bdb5068`](https://redirect.github.com/nodejs/node/commit/a64bdb5068)]
- **doc**: fix overstated Date header requirement in response.sendDate
(Kit Dallege)
[#&#8203;62206](https://redirect.github.com/nodejs/node/pull/62206)
-
\[[`02797de923`](https://redirect.github.com/nodejs/node/commit/02797de923)]
- **doc**: fix small environment\_variables typo (chris)
[#&#8203;62279](https://redirect.github.com/nodejs/node/pull/62279)
-
\[[`f22ebdc809`](https://redirect.github.com/nodejs/node/commit/f22ebdc809)]
- **doc**: fix small logic error in DETECT\_MODULE\_SYNTAX (René)
[#&#8203;62025](https://redirect.github.com/nodejs/node/pull/62025)
-
\[[`9f4508062a`](https://redirect.github.com/nodejs/node/commit/9f4508062a)]
- **doc**: fix methods being documented as properties in `process.md`
(Antoine du Hamel)
[#&#8203;61765](https://redirect.github.com/nodejs/node/pull/61765)
-
\[[`3ea39ff135`](https://redirect.github.com/nodejs/node/commit/3ea39ff135)]
- **doc**: fix dropdown menu being obscured at <600px due to stacking
context (Jeff)
[#&#8203;61735](https://redirect.github.com/nodejs/node/pull/61735)
-
\[[`c22445079b`](https://redirect.github.com/nodejs/node/commit/c22445079b)]
- **doc**: fix spacing in process message event (Aviv Keller)
[#&#8203;61756](https://redirect.github.com/nodejs/node/pull/61756)
-
\[[`32831b5223`](https://redirect.github.com/nodejs/node/commit/32831b5223)]
- **doc**: fix broken links of net.md (YuSheng Chen)
[#&#8203;61673](https://redirect.github.com/nodejs/node/pull/61673)
-
\[[`005508d509`](https://redirect.github.com/nodejs/node/commit/005508d509)]
- **doc**: remove obsolete Boxstarter automated install (Mike McCready)
[#&#8203;61785](https://redirect.github.com/nodejs/node/pull/61785)
-
\[[`37c2fd6f7d`](https://redirect.github.com/nodejs/node/commit/37c2fd6f7d)]
- **esm**: fix path normalization in `finalizeResolution` (Antoine du
Hamel)
[#&#8203;62080](https://redirect.github.com/nodejs/node/pull/62080)
-
\[[`1769d74613`](https://redirect.github.com/nodejs/node/commit/1769d74613)]
- **esm**: populate separate cache for require(esm) in imported CJS
(Joyee Cheung)
[#&#8203;59679](https://redirect.github.com/nodejs/node/pull/59679)
-
\[[`ee02966ffc`](https://redirect.github.com/nodejs/node/commit/ee02966ffc)]
- **http**: fix keep-alive socket reuse race in requestOnFinish (Martin
Slota)
[#&#8203;61710](https://redirect.github.com/nodejs/node/pull/61710)
-
\[[`2fdb5ce6cc`](https://redirect.github.com/nodejs/node/commit/2fdb5ce6cc)]
- **http2**: fix FileHandle leak in respondWithFile (sangwook)
[#&#8203;61707](https://redirect.github.com/nodejs/node/pull/61707)
-
\[[`aa2c1eca04`](https://redirect.github.com/nodejs/node/commit/aa2c1eca04)]
- **lib**: fix source map url parse in dynamic imports (Chengzhong Wu)
[#&#8203;61990](https://redirect.github.com/nodejs/node/pull/61990)
-
\[[`785b00cbeb`](https://redirect.github.com/nodejs/node/commit/785b00cbeb)]
- **meta**: pass release version to release worker (flakey5)
[#&#8203;62777](https://redirect.github.com/nodejs/node/pull/62777)
-
\[[`447fb9a0b5`](https://redirect.github.com/nodejs/node/commit/447fb9a0b5)]
- **meta**: persist sccache daemon until end of build workflows (René)
[#&#8203;61639](https://redirect.github.com/nodejs/node/pull/61639)
-
\[[`5065a0acb3`](https://redirect.github.com/nodejs/node/commit/5065a0acb3)]
- **module**: do not invoke resolve hooks twice for imported cjs (Joyee
Cheung)
[#&#8203;61529](https://redirect.github.com/nodejs/node/pull/61529)
-
\[[`9a2e21305d`](https://redirect.github.com/nodejs/node/commit/9a2e21305d)]
- **module**: do not wrap module.\_load when tracing is not enabled
(Joyee Cheung)
[#&#8203;61479](https://redirect.github.com/nodejs/node/pull/61479)
-
\[[`b9240bc063`](https://redirect.github.com/nodejs/node/commit/b9240bc063)]
- **module**: fix sync resolve hooks for require with node: prefixes
(Joyee Cheung)
[#&#8203;61088](https://redirect.github.com/nodejs/node/pull/61088)
-
\[[`2e91b28aaf`](https://redirect.github.com/nodejs/node/commit/2e91b28aaf)]
- **module**: handle null source from async loader hooks in sync hooks
(Joyee Cheung)
[#&#8203;59929](https://redirect.github.com/nodejs/node/pull/59929)
-
\[[`39147c154e`](https://redirect.github.com/nodejs/node/commit/39147c154e)]
- **module**: use sync cjs when importing cts (Marco Ippolito)
[#&#8203;60072](https://redirect.github.com/nodejs/node/pull/60072)
-
\[[`12a2462b2c`](https://redirect.github.com/nodejs/node/commit/12a2462b2c)]
- **module**: only put directly require-d ESM into require.cache (Joyee
Cheung)
[#&#8203;59874](https://redirect.github.com/nodejs/node/pull/59874)
-
\[[`cf39566277`](https://redirect.github.com/nodejs/node/commit/cf39566277)]
- **src**: fix flags argument offset in JSUdpWrap (Weixie Cui)
[#&#8203;61948](https://redirect.github.com/nodejs/node/pull/61948)
-
\[[`578a9a9230`](https://redirect.github.com/nodejs/node/commit/578a9a9230)]
- **src**: clamp WriteUtf8 capacity to INT\_MAX in EncodeInto
(semimikoh)
[#&#8203;62621](https://redirect.github.com/nodejs/node/pull/62621)
-
\[[`57c3035fec`](https://redirect.github.com/nodejs/node/commit/57c3035fec)]
- **stream**: fix decoded fromList chunk boundary check (Thomas Watson)
[#&#8203;61884](https://redirect.github.com/nodejs/node/pull/61884)
-
\[[`57fb008bb8`](https://redirect.github.com/nodejs/node/commit/57fb008bb8)]
- **test**: update tls junk data error expectations (Filip Skokan)
[#&#8203;62629](https://redirect.github.com/nodejs/node/pull/62629)
-
\[[`363f9a9d18`](https://redirect.github.com/nodejs/node/commit/363f9a9d18)]
- **test**: skip `test-url` on `--shared-ada` builds (Antoine du Hamel)
[#&#8203;62019](https://redirect.github.com/nodejs/node/pull/62019)
-
\[[`daaead342b`](https://redirect.github.com/nodejs/node/commit/daaead342b)]
- **test**: simplify encodeInto large buffer regression test (semimikoh)
[#&#8203;62621](https://redirect.github.com/nodejs/node/pull/62621)
-
\[[`ecfa766b41`](https://redirect.github.com/nodejs/node/commit/ecfa766b41)]
- **tools**: fix auto-start-ci (Antoine du Hamel)
[#&#8203;61900](https://redirect.github.com/nodejs/node/pull/61900)
-
\[[`17c0a610af`](https://redirect.github.com/nodejs/node/commit/17c0a610af)]
- **tools**: fix parsing of commit trailers in `lint-release-proposal`
GHA (Antoine du Hamel)
[#&#8203;62077](https://redirect.github.com/nodejs/node/pull/62077)
-
\[[`89ad7dc63b`](https://redirect.github.com/nodejs/node/commit/89ad7dc63b)]
- **tools**: enforce removal of `lts-watch-*` labels on release
proposals (Antoine du Hamel)
[#&#8203;61672](https://redirect.github.com/nodejs/node/pull/61672)
-
\[[`5f9bb8ef0c`](https://redirect.github.com/nodejs/node/commit/5f9bb8ef0c)]
- **tools**: revert tools GHA workflow to ubuntu-latest (Richard Lau)
[#&#8203;62024](https://redirect.github.com/nodejs/node/pull/62024)
-
\[[`977ef80ac1`](https://redirect.github.com/nodejs/node/commit/977ef80ac1)]
- **url**: process crash via malformed UNC hostname in pathToFileURL()
(Nicola Del Gobbo)
[#&#8203;62574](https://redirect.github.com/nodejs/node/pull/62574)
-
\[[`ad8f518a81`](https://redirect.github.com/nodejs/node/commit/ad8f518a81)]
- **zlib**: fix use-after-free when reset() is called during write
(Matteo Collina)
[#&#8203;62325](https://redirect.github.com/nodejs/node/pull/62325)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:55:16 +08:00
DarkSky f626dbd590 fix(server): realtime loading (#14959)
#### PR Dependency Tree


* **PR #14959** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Rewired realtime and copilot services to require their runtime
dependencies, improving reliability and removing nullable/optional
runtime paths.

* **Tests**
* Centralized service creation in tests with helper factories and added
checks ensuring realtime dependency injection is configured as expected.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14959)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 11:54:45 +08:00
renovate[bot] 419fc5d5e0 chore: bump up Recouse/EventSource version to from: "0.1.8" (#14960)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [Recouse/EventSource](https://redirect.github.com/Recouse/EventSource)
| patch | `from: "0.1.7"` → `from: "0.1.8"` |

---

### Release Notes

<details>
<summary>Recouse/EventSource (Recouse/EventSource)</summary>

###
[`v0.1.8`](https://redirect.github.com/Recouse/EventSource/releases/tag/0.1.8)

[Compare
Source](https://redirect.github.com/Recouse/EventSource/compare/0.1.7...0.1.8)

#### What's Changed

- Fix O(n²) performance in ServerEventParser.parse() by
[@&#8203;liefran-sim](https://redirect.github.com/liefran-sim) in
[#&#8203;49](https://redirect.github.com/Recouse/EventSource/pull/49)

#### New Contributors

- [@&#8203;liefran-sim](https://redirect.github.com/liefran-sim) made
their first contribution in
[#&#8203;49](https://redirect.github.com/Recouse/EventSource/pull/49)

**Full Changelog**:
<https://github.com/Recouse/EventSource/compare/0.1.7...0.1.8>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 10:15:11 +08:00
DarkSky 1201f7c350 chore: bump rspack (#14957)
#### PR Dependency Tree


* **PR #14957** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
  * Updated minimum Node version requirement to 22.12.0 or later.
* Updated build tool dependencies including rspack and related packages.
  * Removed CI-specific logging behavior from development server.
* Migrated to native HTML plugin integration for improved build
efficiency.
* Simplified build configuration by removing unused experimental
options.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14957)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 04:18:49 +08:00
DarkSky 4b4def3a11 feat(server): gemini embedding 2 support (#14956)
#### PR Dependency Tree


* **PR #14956** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
* Improved Gemini Vertex provider configuration validation logic for
enhanced reliability.
  * Refined Google Vertex publisher base URL construction handling.

* **Tests**
  * Added test coverage for Gemini Embedding 2 model resolution.
* Added test coverage for Gemini Vertex provider Google Cloud
integration.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14956)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 04:12:49 +08:00
renovate[bot] 2b22fe4692 chore: bump up nestjs (#13791)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@nestjs/apollo](https://redirect.github.com/nestjs/graphql) |
[`13.2.4` →
`13.4.0`](https://renovatebot.com/diffs/npm/@nestjs%2fapollo/13.2.4/13.4.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fapollo/13.4.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fapollo/13.2.4/13.4.0?slim=true)
|
| [@nestjs/common](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.18/11.1.20?slim=true)
|
| [@nestjs/core](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.18/11.1.20?slim=true)
|
| [@nestjs/graphql](https://redirect.github.com/nestjs/graphql) |
[`13.2.5` →
`13.4.0`](https://renovatebot.com/diffs/npm/@nestjs%2fgraphql/13.2.5/13.4.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fgraphql/13.4.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fgraphql/13.2.5/13.4.0?slim=true)
|
| [@nestjs/platform-express](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.18/11.1.20?slim=true)
|
| [@nestjs/platform-socket.io](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.18/11.1.20?slim=true)
|
| [@nestjs/schedule](https://redirect.github.com/nestjs/schedule) |
[`6.1.1` →
`6.1.3`](https://renovatebot.com/diffs/npm/@nestjs%2fschedule/6.1.1/6.1.3)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fschedule/6.1.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fschedule/6.1.1/6.1.3?slim=true)
|
| [@nestjs/swagger](https://redirect.github.com/nestjs/swagger) |
[`11.2.7` →
`11.4.2`](https://renovatebot.com/diffs/npm/@nestjs%2fswagger/11.2.7/11.4.2)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fswagger/11.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fswagger/11.2.7/11.4.2?slim=true)
|
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.18/11.1.20?slim=true)
|

---

### Release Notes

<details>
<summary>nestjs/graphql (@&#8203;nestjs/apollo)</summary>

###
[`v13.4.0`](https://redirect.github.com/nestjs/graphql/releases/tag/v13.4.0)

[Compare
Source](https://redirect.github.com/nestjs/graphql/compare/v13.3.0...v13.4.0)

#### 13.4.0 (2026-04-30)

##### Features

- `apollo`, `graphql`, `mercurius`
- [#&#8203;3811](https://redirect.github.com/nestjs/graphql/pull/3811)
feat(graphql): Add registerIn option for module-scoped type filtering
([@&#8203;joe-re](https://redirect.github.com/joe-re))

##### Bug fixes

- `graphql`
- [#&#8203;3959](https://redirect.github.com/nestjs/graphql/pull/3959)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
stop double-registering PickType inputs
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3960](https://redirect.github.com/nestjs/graphql/pull/3960)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
de-duplicate per-target metadata in TargetMetadataCollection
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- `apollo`, `graphql`
- [#&#8203;3962](https://redirect.github.com/nestjs/graphql/pull/3962)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
restore Timestamp scalar parsers in federation factory
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Enhancements

- `graphql`
- [#&#8203;3963](https://redirect.github.com/nestjs/graphql/pull/3963)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
validate registerEnumType/createUnionType options eagerly
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Dependencies

- `graphql`
- [#&#8203;3954](https://redirect.github.com/nestjs/graphql/pull/3954)
fix(deps): update graphql-tools monorepo
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 3

- Masato Noguchi ([@&#8203;joe-re](https://redirect.github.com/joe-re))
- Mateus Welter Goettems
([@&#8203;mateuswgoettems](https://redirect.github.com/mateuswgoettems))
- Yogeshwaran C
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

###
[`v13.3.0`](https://redirect.github.com/nestjs/graphql/releases/tag/v13.3.0)

[Compare
Source](https://redirect.github.com/nestjs/graphql/compare/v13.2.5...v13.3.0)

#### 13.3.0 (2026-04-22)

##### Bug fixes

- `graphql`
- [#&#8203;3949](https://redirect.github.com/nestjs/graphql/pull/3949)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
count args for parenless arrow functions
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3952](https://redirect.github.com/nestjs/graphql/pull/3952)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
keep class directive when a field has the same SDL
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3946](https://redirect.github.com/nestjs/graphql/pull/3946)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
emit enum key for Args defaultValue in generated SDL
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3934](https://redirect.github.com/nestjs/graphql/pull/3934)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
treat single-key string enums as enums in plugin type detection
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3939](https://redirect.github.com/nestjs/graphql/pull/3939)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
preserve ResolveField options for all overloads
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- `apollo`
- [#&#8203;3940](https://redirect.github.com/nestjs/graphql/pull/3940)
fix(apollo): preserve HTTP 200 for execution-level GraphQL errors
([@&#8203;maruthang](https://redirect.github.com/maruthang))

##### Enhancements

- `graphql`
- [#&#8203;3838](https://redirect.github.com/nestjs/graphql/pull/3838)
perf(graphql): bypass ExternalContextCreator for scalar ResolveField
fast-path ([@&#8203;ArielSafar](https://redirect.github.com/ArielSafar))
- [#&#8203;3950](https://redirect.github.com/nestjs/graphql/pull/3950)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
forward specifiedByURL and extensions on custom scalars
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3951](https://redirect.github.com/nestjs/graphql/pull/3951)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
accept array of SDL strings in
[@&#8203;Directive](https://redirect.github.com/Directive)
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3944](https://redirect.github.com/nestjs/graphql/pull/3944)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
emit clearer error when nested object type is used in mapped input
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3943](https://redirect.github.com/nestjs/graphql/pull/3943)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
add conditional exports for browser shim
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3942](https://redirect.github.com/nestjs/graphql/pull/3942)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
default federation to v2.12 directives
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3936](https://redirect.github.com/nestjs/graphql/pull/3936)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
allow CustomScalar methods to return null
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- `apollo`, `graphql`
- [#&#8203;3948](https://redirect.github.com/nestjs/graphql/pull/3948)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
support directives on enums and unions
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Dependencies

- `graphql`
- [#&#8203;3925](https://redirect.github.com/nestjs/graphql/pull/3925)
chore(deps): update dependency ts-morph to v28
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3918](https://redirect.github.com/nestjs/graphql/pull/3918)
fix(deps): update graphql-tools monorepo
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- `mercurius`
- [#&#8203;3928](https://redirect.github.com/nestjs/graphql/pull/3928)
chore(deps): update dependency fastify to v5.8.5
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3858](https://redirect.github.com/nestjs/graphql/pull/3858)
chore(deps): update dependency
[@&#8203;mercuriusjs/gateway](https://redirect.github.com/mercuriusjs/gateway)
to v5.2.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3920](https://redirect.github.com/nestjs/graphql/pull/3920)
chore(deps): update dependency mercurius to v16.9.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 3

- Ariel Safar
([@&#8203;ArielSafar](https://redirect.github.com/ArielSafar))
- Maruthan G
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- Yogeshwaran C
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

###
[`v13.2.5`](https://redirect.github.com/nestjs/graphql/releases/tag/v13.2.5)

[Compare
Source](https://redirect.github.com/nestjs/graphql/compare/v13.2.4...v13.2.5)

##### 13.2.5 (2026-04-09)

##### Bug fixes

- `graphql`
- [#&#8203;3846](https://redirect.github.com/nestjs/graphql/pull/3846)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
handle definitions factory typename option
([@&#8203;NicolasGn](https://redirect.github.com/NicolasGn))

##### Enhancements

- `graphql`
- [#&#8203;3889](https://redirect.github.com/nestjs/graphql/pull/3889)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
add stopOnApplicationShutdown option for graceful shutdown
([@&#8203;dgfh0450](https://redirect.github.com/dgfh0450))

##### Dependencies

- `graphql`
- [#&#8203;3894](https://redirect.github.com/nestjs/graphql/pull/3894)
fix(deps): update dependency graphql-ws to v6.0.8
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3852](https://redirect.github.com/nestjs/graphql/pull/3852)
chore(deps): update dependency graphql to v16.13.2
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3888](https://redirect.github.com/nestjs/graphql/pull/3888)
fix(deps): update dependency ws to v8.20.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3901](https://redirect.github.com/nestjs/graphql/pull/3901)
fix(deps): update dependency
[@&#8203;nestjs/mapped-types](https://redirect.github.com/nestjs/mapped-types)
to v2.1.1
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3904](https://redirect.github.com/nestjs/graphql/pull/3904)
fix(deps): update dependency lodash to v4.18.1 \[security]
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- `apollo`
- [#&#8203;3902](https://redirect.github.com/nestjs/graphql/pull/3902)
fix(deps): update dependency lodash.omit to v4.18.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3897](https://redirect.github.com/nestjs/graphql/pull/3897)
chore(deps): update dependency
[@&#8203;apollo/server](https://redirect.github.com/apollo/server) to
v5.5.0 \[security]
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3881](https://redirect.github.com/nestjs/graphql/pull/3881)
chore(deps): update dependency
[@&#8203;apollo/gateway](https://redirect.github.com/apollo/gateway) to
v2.10.5 \[security]
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- `mercurius`
- [#&#8203;3899](https://redirect.github.com/nestjs/graphql/pull/3899)
chore(deps): update dependency
[@&#8203;mercuriusjs/federation](https://redirect.github.com/mercuriusjs/federation)
to v5.1.1
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3890](https://redirect.github.com/nestjs/graphql/pull/3890)
chore(deps): update dependency fastify to v5.8.4
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3868](https://redirect.github.com/nestjs/graphql/pull/3868)
chore(deps): update dependency mercurius to v16.8.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 2

- Nicolas Guégan
([@&#8203;NicolasGn](https://redirect.github.com/NicolasGn))
- YoonDH ([@&#8203;dgfh0450](https://redirect.github.com/dgfh0450))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/compare/v11.1.19...7caeb3fb70de81085c4c3e8502a2a0e62e4f8eda)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

#### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/core)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/compare/v11.1.19...7caeb3fb70de81085c4c3e8502a2a0e62e4f8eda)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

##### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-express)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/compare/v11.1.19...7caeb3fb70de81085c4c3e8502a2a0e62e4f8eda)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

##### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-socket.io)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.20)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

##### v11.1.20 (2026-05-13)

##### Bug fixes

- `core`, `testing`
- [#&#8203;16939](https://redirect.github.com/nestjs/nest/pull/16939)
fix(core): fix deeply nested transient providers resolution
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))
- `core`
- [#&#8203;16861](https://redirect.github.com/nestjs/nest/pull/16861)
fix(core): fix [@&#8203;Sse](https://redirect.github.com/Sse) losing
events on complete
([@&#8203;MatthiasBrehmer](https://redirect.github.com/MatthiasBrehmer))
- [#&#8203;16753](https://redirect.github.com/nestjs/nest/pull/16753)
fix(core): defer sse writehead until after lifecycle completes
([@&#8203;jkalberer](https://redirect.github.com/jkalberer))
- [#&#8203;16782](https://redirect.github.com/nestjs/nest/pull/16782)
fix(core): use strict null check for SSE message id
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- `microservices`
- [#&#8203;16850](https://redirect.github.com/nestjs/nest/pull/16850)
fix(microservices): ServerRMQ crashes at boot when
[@&#8203;MessagePattern](https://redirect.github.com/MessagePattern)(undefined)
is combined with wildcards: true
([@&#8203;lavieennoir](https://redirect.github.com/lavieennoir))
- `common`
- [#&#8203;16845](https://redirect.github.com/nestjs/nest/pull/16845)
fix(common): accept zero timestamp in parse date pipe
([@&#8203;Mysh3ll](https://redirect.github.com/Mysh3ll))
- `platform-socket.io`
- [#&#8203;16742](https://redirect.github.com/nestjs/nest/pull/16742)
fix(socket.io): Deduplicate disconnect listener in bindMessageHandlers
([@&#8203;fru1tworld](https://redirect.github.com/fru1tworld))

##### Enhancements

- `microservices`
- [#&#8203;16676](https://redirect.github.com/nestjs/nest/pull/16676)
feat(microservices): add return buffers option for binary data
([@&#8203;Forceres](https://redirect.github.com/Forceres))
- [#&#8203;16826](https://redirect.github.com/nestjs/nest/pull/16826)
feat(microservices): handle rmq blocked/unblocked connection events
([@&#8203;thisalihassan](https://redirect.github.com/thisalihassan))
- `common`
- [#&#8203;16902](https://redirect.github.com/nestjs/nest/pull/16902)
fix(common): filetype validator buffer message
([@&#8203;QusaiAlbonni](https://redirect.github.com/QusaiAlbonni))
- `platform-express`
- [#&#8203;16844](https://redirect.github.com/nestjs/nest/pull/16844)
feat(platform-express): add defParamCharset to MulterOptions
([@&#8203;starnayuta](https://redirect.github.com/starnayuta))

##### Dependencies

- `platform-ws`
- [#&#8203;16941](https://redirect.github.com/nestjs/nest/pull/16941)
chore(deps): bump ws from 8.20.0 to 8.20.1
([@&#8203;dependabot\[bot\]](https://redirect.github.com/apps/dependabot))

##### Committers: 13

- Ali Hassan
([@&#8203;thisalihassan](https://redirect.github.com/thisalihassan))
- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Dmytro Khyzhniak
([@&#8203;lavieennoir](https://redirect.github.com/lavieennoir))
- Harsh Rathod
([@&#8203;harshrathod50](https://redirect.github.com/harshrathod50))
- IlyaCredo ([@&#8203;Forceres](https://redirect.github.com/Forceres))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))
- Mysh3ll ([@&#8203;Mysh3ll](https://redirect.github.com/Mysh3ll))
- [@&#8203;MatthiasBrehmer](https://redirect.github.com/MatthiasBrehmer)
- [@&#8203;QusaiAlbonni](https://redirect.github.com/QusaiAlbonni)
- [@&#8203;jkalberer](https://redirect.github.com/jkalberer)
- [@&#8203;pazaderey](https://redirect.github.com/pazaderey)
- fru1tworld
([@&#8203;fru1tworld](https://redirect.github.com/fru1tworld))
- starnayuta
([@&#8203;starnayuta](https://redirect.github.com/starnayuta))

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

#### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/schedule (@&#8203;nestjs/schedule)</summary>

###
[`v6.1.3`](https://redirect.github.com/nestjs/schedule/releases/tag/6.1.3)

[Compare
Source](https://redirect.github.com/nestjs/schedule/compare/6.1.2...6.1.3)

#### What's Changed

- feat(cron): add initialDelay option to defer first job execution by
[@&#8203;kyungseopk1m](https://redirect.github.com/kyungseopk1m) in
[#&#8203;2251](https://redirect.github.com/nestjs/schedule/pull/2251)

**Full Changelog**:
<https://github.com/nestjs/schedule/compare/6.1.2...6.1.3>

###
[`v6.1.2`](https://redirect.github.com/nestjs/schedule/releases/tag/6.1.2)

[Compare
Source](https://redirect.github.com/nestjs/schedule/compare/6.1.1...6.1.2)

- Merge pull request
[#&#8203;2247](https://redirect.github.com/nestjs/schedule/issues/2247)
from kyungseopk1m/feat/cron-initial-delay
([`a57ce2c`](https://redirect.github.com/nestjs/schedule/commit/a57ce2c))
- chore(deps): update dependency prettier to v3.8.3
([#&#8203;2248](https://redirect.github.com/nestjs/schedule/issues/2248))
([`bb3490d`](https://redirect.github.com/nestjs/schedule/commit/bb3490d))
- feat(cron): add initialDelay option to defer first job execution
([`1c5677f`](https://redirect.github.com/nestjs/schedule/commit/1c5677f))
- Merge pull request
[#&#8203;2245](https://redirect.github.com/nestjs/schedule/issues/2245)
from nestjs/renovate/nest-monorepo
([`59046bd`](https://redirect.github.com/nestjs/schedule/commit/59046bd))
- Merge pull request
[#&#8203;2246](https://redirect.github.com/nestjs/schedule/issues/2246)
from nestjs/renovate/oxlint-monorepo
([`be4eee3`](https://redirect.github.com/nestjs/schedule/commit/be4eee3))
- chore(deps): update dependency oxlint to v1.60.0
([`32a9ce2`](https://redirect.github.com/nestjs/schedule/commit/32a9ce2))
- chore(deps): update nest monorepo to v11.1.19
([`7d3844f`](https://redirect.github.com/nestjs/schedule/commit/7d3844f))
- chore: migrate to oxlint, vitest, ts6
([`29de71b`](https://redirect.github.com/nestjs/schedule/commit/29de71b))
- chore(deps): update dependency globals to v17.5.0
([#&#8203;2244](https://redirect.github.com/nestjs/schedule/issues/2244))
([`6c62cca`](https://redirect.github.com/nestjs/schedule/commit/6c62cca))
- chore(deps): update dependency sinon to v21.1.2
([#&#8203;2243](https://redirect.github.com/nestjs/schedule/issues/2243))
([`ee3b31a`](https://redirect.github.com/nestjs/schedule/commit/ee3b31a))
- chore(deps): update dependency sinon to v21.1.1
([#&#8203;2241](https://redirect.github.com/nestjs/schedule/issues/2241))
([`eba9799`](https://redirect.github.com/nestjs/schedule/commit/eba9799))
- Merge pull request
[#&#8203;2242](https://redirect.github.com/nestjs/schedule/issues/2242)
from nestjs/renovate/prettier-3.x
([`c3ad0f7`](https://redirect.github.com/nestjs/schedule/commit/c3ad0f7))
- chore(deps): update dependency prettier to v3.8.2
([`798e2a9`](https://redirect.github.com/nestjs/schedule/commit/798e2a9))
- Merge pull request
[#&#8203;2199](https://redirect.github.com/nestjs/schedule/issues/2199)
from nestjs/renovate/cimg-node-24.x
([`a05354a`](https://redirect.github.com/nestjs/schedule/commit/a05354a))
- chore(deps): update dependency typescript-eslint to v8.58.1
([#&#8203;2240](https://redirect.github.com/nestjs/schedule/issues/2240))
([`0367ac1`](https://redirect.github.com/nestjs/schedule/commit/0367ac1))
- chore(deps): update dependency eslint to v10.2.0
([#&#8203;2239](https://redirect.github.com/nestjs/schedule/issues/2239))
([`fa93e06`](https://redirect.github.com/nestjs/schedule/commit/fa93e06))
- chore(deps): update nest monorepo to v11.1.18
([#&#8203;2238](https://redirect.github.com/nestjs/schedule/issues/2238))
([`8cd4c02`](https://redirect.github.com/nestjs/schedule/commit/8cd4c02))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to v24.12.2
([#&#8203;2237](https://redirect.github.com/nestjs/schedule/issues/2237))
([`01482df`](https://redirect.github.com/nestjs/schedule/commit/01482df))
- chore(deps): update dependency
[@&#8203;types/sinon](https://redirect.github.com/types/sinon) to
v21.0.1
([#&#8203;2236](https://redirect.github.com/nestjs/schedule/issues/2236))
([`f05b5bd`](https://redirect.github.com/nestjs/schedule/commit/f05b5bd))
- chore(deps): update dependency ts-jest to v29.4.9
([#&#8203;2235](https://redirect.github.com/nestjs/schedule/issues/2235))
([`af545e6`](https://redirect.github.com/nestjs/schedule/commit/af545e6))
- chore(deps): update dependency typescript-eslint to v8.58.0
([#&#8203;2233](https://redirect.github.com/nestjs/schedule/issues/2233))
([`4dad22a`](https://redirect.github.com/nestjs/schedule/commit/4dad22a))
- chore(deps): update node.js to v24.14.1
([`28db9bc`](https://redirect.github.com/nestjs/schedule/commit/28db9bc))
- chore(deps): update dependency eslint to v10.1.0
([#&#8203;2232](https://redirect.github.com/nestjs/schedule/issues/2232))
([`413f390`](https://redirect.github.com/nestjs/schedule/commit/413f390))
- chore(deps): update nest monorepo to v11.1.17
([#&#8203;2230](https://redirect.github.com/nestjs/schedule/issues/2230))
([`46c2bc5`](https://redirect.github.com/nestjs/schedule/commit/46c2bc5))
- chore(deps): update dependency typescript-eslint to v8.57.1
([#&#8203;2231](https://redirect.github.com/nestjs/schedule/issues/2231))
([`8fd063b`](https://redirect.github.com/nestjs/schedule/commit/8fd063b))
- chore(deps): update dependency sinon to v21.0.3
([#&#8203;2229](https://redirect.github.com/nestjs/schedule/issues/2229))
([`1671ad9`](https://redirect.github.com/nestjs/schedule/commit/1671ad9))
- chore(deps): update commitlint monorepo to v20.5.0
([#&#8203;2228](https://redirect.github.com/nestjs/schedule/issues/2228))
([`2ecd2f1`](https://redirect.github.com/nestjs/schedule/commit/2ecd2f1))
- chore(deps): update dependency lint-staged to v16.4.0
([#&#8203;2227](https://redirect.github.com/nestjs/schedule/issues/2227))
([`aa0de01`](https://redirect.github.com/nestjs/schedule/commit/aa0de01))
- chore(deps): update commitlint monorepo to v20.4.4
([#&#8203;2226](https://redirect.github.com/nestjs/schedule/issues/2226))
([`75034fe`](https://redirect.github.com/nestjs/schedule/commit/75034fe))
- chore(deps): update dependency lint-staged to v16.3.3
([#&#8203;2225](https://redirect.github.com/nestjs/schedule/issues/2225))
([`f1c7d31`](https://redirect.github.com/nestjs/schedule/commit/f1c7d31))
- chore(deps): update dependency jest to v30.3.0
([#&#8203;2224](https://redirect.github.com/nestjs/schedule/issues/2224))
([`1a208d4`](https://redirect.github.com/nestjs/schedule/commit/1a208d4))
- chore(deps): update dependency typescript-eslint to v8.57.0
([#&#8203;2223](https://redirect.github.com/nestjs/schedule/issues/2223))
([`60dd2c9`](https://redirect.github.com/nestjs/schedule/commit/60dd2c9))
- chore(deps): update dependency eslint to v10.0.3
([#&#8203;2221](https://redirect.github.com/nestjs/schedule/issues/2221))
([`791b6ba`](https://redirect.github.com/nestjs/schedule/commit/791b6ba))
- chore(deps): update dependency
[@&#8203;eslint/eslintrc](https://redirect.github.com/eslint/eslintrc)
to v3.3.5
([#&#8203;2220](https://redirect.github.com/nestjs/schedule/issues/2220))
([`0da1ca7`](https://redirect.github.com/nestjs/schedule/commit/0da1ca7))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to v24.12.0
([#&#8203;2219](https://redirect.github.com/nestjs/schedule/issues/2219))
([`934a93e`](https://redirect.github.com/nestjs/schedule/commit/934a93e))
- chore(deps): update nest monorepo to v11.1.16
([#&#8203;2218](https://redirect.github.com/nestjs/schedule/issues/2218))
([`5f44e9b`](https://redirect.github.com/nestjs/schedule/commit/5f44e9b))
- chore(deps): update dependency sinon to v21.0.2
([#&#8203;2217](https://redirect.github.com/nestjs/schedule/issues/2217))
([`b807746`](https://redirect.github.com/nestjs/schedule/commit/b807746))
- chore(deps): update dependency lint-staged to v16.3.2
([#&#8203;2216](https://redirect.github.com/nestjs/schedule/issues/2216))
([`4ca32bd`](https://redirect.github.com/nestjs/schedule/commit/4ca32bd))
- chore(deps): update commitlint monorepo to v20.4.3
([#&#8203;2215](https://redirect.github.com/nestjs/schedule/issues/2215))
([`d3ceb76`](https://redirect.github.com/nestjs/schedule/commit/d3ceb76))
- chore(deps): update nest monorepo to v11.1.15
([#&#8203;2214](https://redirect.github.com/nestjs/schedule/issues/2214))
([`b084ffc`](https://redirect.github.com/nestjs/schedule/commit/b084ffc))
- chore(deps): update dependency lint-staged to v16.3.1
([#&#8203;2213](https://redirect.github.com/nestjs/schedule/issues/2213))
([`8a201b2`](https://redirect.github.com/nestjs/schedule/commit/8a201b2))
- chore(deps): update dependency globals to v17.4.0
([#&#8203;2212](https://redirect.github.com/nestjs/schedule/issues/2212))
([`6f61793`](https://redirect.github.com/nestjs/schedule/commit/6f61793))
- chore(deps): update dependency lint-staged to v16.3.0
([#&#8203;2211](https://redirect.github.com/nestjs/schedule/issues/2211))
([`aa9213a`](https://redirect.github.com/nestjs/schedule/commit/aa9213a))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to v24.11.0
([#&#8203;2210](https://redirect.github.com/nestjs/schedule/issues/2210))
([`c70b928`](https://redirect.github.com/nestjs/schedule/commit/c70b928))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.15
([#&#8203;2209](https://redirect.github.com/nestjs/schedule/issues/2209))
([`0f596b9`](https://redirect.github.com/nestjs/schedule/commit/0f596b9))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.14
([#&#8203;2208](https://redirect.github.com/nestjs/schedule/issues/2208))
([`dac8cca`](https://redirect.github.com/nestjs/schedule/commit/dac8cca))
- chore(deps): update dependency eslint to v10.0.2
([#&#8203;2207](https://redirect.github.com/nestjs/schedule/issues/2207))
([`abe6fce`](https://redirect.github.com/nestjs/schedule/commit/abe6fce))
- chore(deps): update dependency
[@&#8203;eslint/eslintrc](https://redirect.github.com/eslint/eslintrc)
to v3.3.4
([#&#8203;2206](https://redirect.github.com/nestjs/schedule/issues/2206))
([`cb32a40`](https://redirect.github.com/nestjs/schedule/commit/cb32a40))
- chore(deps): update dependency typescript-eslint to v8.56.1
([#&#8203;2205](https://redirect.github.com/nestjs/schedule/issues/2205))
([`88e1e6c`](https://redirect.github.com/nestjs/schedule/commit/88e1e6c))
- chore(deps): update dependency eslint to v10.0.1
([#&#8203;2204](https://redirect.github.com/nestjs/schedule/issues/2204))
([`55e5406`](https://redirect.github.com/nestjs/schedule/commit/55e5406))
- chore(deps): update commitlint monorepo to v20.4.2
([#&#8203;2203](https://redirect.github.com/nestjs/schedule/issues/2203))
([`4e55d62`](https://redirect.github.com/nestjs/schedule/commit/4e55d62))
- chore(deps): update nest monorepo to v11.1.14
([#&#8203;2202](https://redirect.github.com/nestjs/schedule/issues/2202))
([`d23ea1a`](https://redirect.github.com/nestjs/schedule/commit/d23ea1a))
- chore(deps): update eslint monorepo to v10
([#&#8203;2195](https://redirect.github.com/nestjs/schedule/issues/2195))
([`c2fcbc3`](https://redirect.github.com/nestjs/schedule/commit/c2fcbc3))
- chore(deps): update dependency typescript-eslint to v8.56.0
([#&#8203;2201](https://redirect.github.com/nestjs/schedule/issues/2201))
([`a93ebc4`](https://redirect.github.com/nestjs/schedule/commit/a93ebc4))
- chore(deps): update dependency rimraf to v6.1.3
([#&#8203;2200](https://redirect.github.com/nestjs/schedule/issues/2200))
([`1906e80`](https://redirect.github.com/nestjs/schedule/commit/1906e80))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.13
([#&#8203;2198](https://redirect.github.com/nestjs/schedule/issues/2198))
([`244cb84`](https://redirect.github.com/nestjs/schedule/commit/244cb84))
- chore(deps): update dependency typescript-eslint to v8.55.0
([#&#8203;2197](https://redirect.github.com/nestjs/schedule/issues/2197))
([`6b00083`](https://redirect.github.com/nestjs/schedule/commit/6b00083))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.12
([#&#8203;2196](https://redirect.github.com/nestjs/schedule/issues/2196))
([`b310c95`](https://redirect.github.com/nestjs/schedule/commit/b310c95))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.11
([#&#8203;2194](https://redirect.github.com/nestjs/schedule/issues/2194))
([`d05dca5`](https://redirect.github.com/nestjs/schedule/commit/d05dca5))

</details>

<details>
<summary>nestjs/swagger (@&#8203;nestjs/swagger)</summary>

###
[`v11.4.2`](https://redirect.github.com/nestjs/swagger/compare/11.4.1...b0a35f3b20bedc6e6756f476cee182700a199b6e)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.4.1...11.4.2)

###
[`v11.4.1`](https://redirect.github.com/nestjs/swagger/compare/11.4.0...14bd8f58d6011a1be03e266e39e472be0d4d3795)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.4.0...11.4.1)

###
[`v11.4.0`](https://redirect.github.com/nestjs/swagger/releases/tag/11.4.0)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.3.2...11.4.0)

#### 11.4.0 (2026-04-22)

##### Features

- [#&#8203;3868](https://redirect.github.com/nestjs/swagger/pull/3868)
feat(plugin): auto-mark optional
[@&#8203;Query](https://redirect.github.com/Query) parameters as
required: false
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3725](https://redirect.github.com/nestjs/swagger/pull/3725)
feat(swagger): add OpenAPI 3.2 hierarchical tags support
([@&#8203;apt-bh](https://redirect.github.com/apt-bh))

##### Bug fixes

- [#&#8203;3874](https://redirect.github.com/nestjs/swagger/pull/3874)
fix(document-builder): accept multi-digit OpenAPI version segments
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3873](https://redirect.github.com/nestjs/swagger/pull/3873)
fix(plugin): strip regex delimiters and flags from
[@&#8203;Matches](https://redirect.github.com/Matches) patterns
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3870](https://redirect.github.com/nestjs/swagger/pull/3870)
fix(decorators): forward all OpenAPI parameter fields in
[@&#8203;ApiHeader](https://redirect.github.com/ApiHeader)
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3872](https://redirect.github.com/nestjs/swagger/pull/3872)
fix(plugin): emit [@&#8203;throws](https://redirect.github.com/throws)
descriptions as proper string literals
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3782](https://redirect.github.com/nestjs/swagger/pull/3782)
fix(schema): preserve example metadata for non-body params with named
types ([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3761](https://redirect.github.com/nestjs/swagger/pull/3761)
fix(plugin): support boolean literal types and boolean enum values
([@&#8203;lucreiss](https://redirect.github.com/lucreiss))

##### Enhancements

- [#&#8203;3865](https://redirect.github.com/nestjs/swagger/pull/3865)
feat(schema-object-factory): include class name chain in circular
dependency errors
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Committers: 4

- Lu R A ([@&#8203;lucreiss](https://redirect.github.com/lucreiss))
- Maruthan G
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- Yogeshwaran C
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [@&#8203;apt-bh](https://redirect.github.com/apt-bh)

###
[`v11.3.2`](https://redirect.github.com/nestjs/swagger/compare/11.3.1...b16a1e19a8b7161e13c01c636acf3a187eabbd06)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.3.1...11.3.2)

###
[`v11.3.1`](https://redirect.github.com/nestjs/swagger/compare/11.3.0...93744af0bb923daeebcc2b674bc7957d778d3953)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.3.0...11.3.1)

###
[`v11.3.0`](https://redirect.github.com/nestjs/swagger/releases/tag/11.3.0)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.2.7...11.3.0)

#### 11.3.0 (2026-04-15)

##### Bug fixes

- [#&#8203;3826](https://redirect.github.com/nestjs/swagger/pull/3826)
fix: support nullable field in
[@&#8203;ApiResponse](https://redirect.github.com/ApiResponse) decorator
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3784](https://redirect.github.com/nestjs/swagger/pull/3784)
fix(schema): include type field when nullable is used with allOf
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3774](https://redirect.github.com/nestjs/swagger/pull/3774)
fix enum issue
([@&#8203;SupunGeethanjana](https://redirect.github.com/SupunGeethanjana))
- [#&#8203;3798](https://redirect.github.com/nestjs/swagger/pull/3798)
fix(plugin): normalize workspace package import paths in metadata
generator ([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3821](https://redirect.github.com/nestjs/swagger/pull/3821)
fix(plugin): handle same-file type references in SWC readonly metadata
generation ([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3822](https://redirect.github.com/nestjs/swagger/pull/3822)
fix(type-helpers): eagerly apply plugin metadata properties in mapped
type helpers
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3840](https://redirect.github.com/nestjs/swagger/pull/3840)
fix: use child class type when re-declaring an inherited
[@&#8203;ApiProperty](https://redirect.github.com/ApiProperty)
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))

##### Enhancements

- [#&#8203;3449](https://redirect.github.com/nestjs/swagger/pull/3449)
feat(api-header): add example property to ApiHeader decorator
([@&#8203;leemhoon00](https://redirect.github.com/leemhoon00))
- [#&#8203;3787](https://redirect.github.com/nestjs/swagger/pull/3787)
feat(decorators): support RegExp instances in
[@&#8203;ApiProperty](https://redirect.github.com/ApiProperty)({ pattern
}) ([@&#8203;temrjan](https://redirect.github.com/temrjan))
- [#&#8203;3699](https://redirect.github.com/nestjs/swagger/pull/3699)
feat(api-body): add support for encoding in ApiBody decorator
([@&#8203;lamuertepeluda](https://redirect.github.com/lamuertepeluda))
- [#&#8203;3824](https://redirect.github.com/nestjs/swagger/pull/3824)
feat: support async patchDocumentOnRequest hook
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3834](https://redirect.github.com/nestjs/swagger/pull/3834)
feat: expose generateSchema utility for programmatic schema access
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3836](https://redirect.github.com/nestjs/swagger/pull/3836)
feat(plugin): add autoFillEnumName option to suppress duplicate enum
schemas
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3837](https://redirect.github.com/nestjs/swagger/pull/3837)
feat: merge descriptions when multiple decorators share the same HTTP
status code
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3839](https://redirect.github.com/nestjs/swagger/pull/3839)
feat: add excludeDynamicDefaults option to strip runtime-evaluated
schema defaults
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3841](https://redirect.github.com/nestjs/swagger/pull/3841)
feat: add DeepPartialType mapped-type helper for recursive optional
properties
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))

##### Dependencies

- [#&#8203;3850](https://redirect.github.com/nestjs/swagger/pull/3850)
fix(deps): update dependency swagger-ui-dist to v5.32.4
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 7

- JongHun Lim
([@&#8203;leemhoon00](https://redirect.github.com/leemhoon00))
- Maruthan G
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- Rajasekar Janakiraman
([@&#8203;rajasekar33](https://redirect.github.com/rajasekar33))
- Supun Geethanjana Jayasinghe
([@&#8203;SupunGeethanjana](https://redirect.github.com/SupunGeethanjana))
- Temrjan ([@&#8203;temrjan](https://redirect.github.com/temrjan))
- Vito Macchia
([@&#8203;lamuertepeluda](https://redirect.github.com/lamuertepeluda))
-
[@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTYuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 01:25:23 +08:00
DarkSky 659072183c chore: bump deps 2026-05-13 22:26:02 +08:00
DarkSky e222f06e94 feat(editor): extract chat runtime (#14937)
#### PR Dependency Tree


* **PR #14937** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Centralized AI event system and a runtime powering chat sessions and
actions.

* **Improvements**
* Chat UI (composer, messages, toolbar, tabs, panels) now syncs with
runtime snapshots for more consistent state.
* Improved session/tab lifecycle (create, fork, delete), context
embedding status, and history handling.
* More reliable send/stop/retry flows, better telemetry scoping, and
clearer upgrade/login/insert-template prompts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-13 21:57:50 +08:00
DarkSky 322f2ba986 fix(server): migrate old tables (#14954) 2026-05-13 21:57:28 +08:00
401 changed files with 38266 additions and 12086 deletions
+16
View File
@@ -300,6 +300,22 @@
}
}
},
"permission": {
"type": "object",
"description": "Configuration for permission module",
"properties": {
"readModel": {
"type": "string",
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
"default": "projection"
},
"fallbackLegacyLoader": {
"type": "boolean",
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
"default": false
}
}
},
"storages": {
"type": "object",
"description": "Configuration for storages module",
+1 -1
View File
@@ -1 +1 @@
22.22.2
22.22.3
Generated
+200 -15
View File
@@ -22,6 +22,16 @@ dependencies = [
"pom",
]
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -33,6 +43,20 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "affine_common"
version = "0.1.0"
@@ -119,7 +143,6 @@ dependencies = [
"mermaid-rs-renderer",
"objc2",
"objc2-foundation",
"sqlx",
"thiserror 2.0.18",
"tokio",
"typst",
@@ -189,11 +212,13 @@ dependencies = [
name = "affine_server_native"
version = "1.0.0"
dependencies = [
"aes-gcm",
"affine_common",
"anyhow",
"base64-simd",
"chrono",
"file-format",
"hex",
"image",
"infer",
"jsonschema",
@@ -207,17 +232,21 @@ dependencies = [
"napi",
"napi-build",
"napi-derive",
"p256",
"rand 0.9.4",
"rayon",
"reqwest",
"rustls",
"schemars",
"serde",
"serde_json",
"sha2",
"sha3",
"tiktoken-rs",
"tokio",
"url",
"v_htmlescape",
"webpki-roots",
"y-octo",
]
@@ -576,6 +605,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.22.1"
@@ -1537,6 +1572,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1544,6 +1591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -1584,6 +1632,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "dary_heap"
version = "0.3.8"
@@ -1806,6 +1863,20 @@ dependencies = [
"cipher",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ecow"
version = "0.2.6"
@@ -1824,6 +1895,26 @@ dependencies = [
"serde",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
@@ -2013,6 +2104,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "file-format"
version = "0.28.0"
@@ -2324,6 +2425,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -2375,6 +2477,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gif"
version = "0.14.1"
@@ -2408,6 +2520,17 @@ dependencies = [
"scroll",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -3625,9 +3748,9 @@ dependencies = [
[[package]]
name = "llm_adapter"
version = "0.2.5"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e139f0a1609d6078293140fb7e281cf2bd5a45a7a29ef39f8606c803be7822"
checksum = "332397a6ccde5ac47fc32b29a2eed447135eb4ff6fd05ffb88dfe937ea9c8211"
dependencies = [
"base64",
"jsonschema",
@@ -4352,6 +4475,12 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -4391,6 +4520,18 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "palette"
version = "0.7.6"
@@ -4752,6 +4893,18 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "pom"
version = "1.1.0"
@@ -4836,6 +4989,15 @@ dependencies = [
"num-integer",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -5334,6 +5496,16 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -5699,6 +5871,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -6040,7 +6226,6 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rustls",
"serde",
"serde_json",
"sha2",
@@ -6050,7 +6235,6 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
"webpki-roots 0.26.11",
]
[[package]]
@@ -7815,6 +7999,16 @@ dependencies = [
"weedle2",
]
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -7853,7 +8047,7 @@ dependencies = [
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots 1.0.6",
"webpki-roots",
]
[[package]]
@@ -8215,15 +8409,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
+4 -1
View File
@@ -16,6 +16,7 @@ resolver = "3"
edition = "2024"
[workspace.dependencies]
aes-gcm = "0.10"
affine_common = { path = "./packages/common/native" }
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
ahash = "0.8"
@@ -39,6 +40,7 @@ resolver = "3"
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
hex = "0.4"
homedir = "0.3"
image = { version = "0.25.9", default-features = false, features = [
"bmp",
@@ -80,6 +82,7 @@ resolver = "3"
ogg = "0.9"
once_cell = "1"
ordered-float = "5"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
parking_lot = "0.12"
path-ext = "0.1.2"
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
@@ -98,6 +101,7 @@ resolver = "3"
screencapturekit = "0.3"
serde = "1"
serde_json = "1"
sha2 = "0.10"
sha3 = "0.10"
smol_str = "0.3"
sqlx = { version = "0.8", default-features = false, features = [
@@ -106,7 +110,6 @@ resolver = "3"
"migrate",
"runtime-tokio",
"sqlite",
"tls-rustls",
] }
strum_macros = "0.27.0"
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
dataSource: this.dataSource,
headerWidget: this.headerWidget,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
import {
DatabaseKanbanViewIcon,
DatabaseTableViewIcon,
TodayIcon,
} from '@blocksuite/icons/lit';
import { insertDatabaseBlockCommand } from '../commands';
@@ -47,6 +48,35 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
},
},
{
name: 'Calendar View',
description: 'Display items by date in a calendar.',
searchAlias: ['database', 'calendar'],
icon: TodayIcon(),
group: '7_Database@1',
when: ({ model }) =>
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertDatabaseBlockCommand, {
viewType: viewPresets.calendarViewMeta.type,
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedDatabaseBlockId }) => {
if (insertedDatabaseBlockId) {
const telemetry = std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:database',
});
}
})
.run();
},
},
{
name: 'Kanban View',
description: 'Visualize data in a dashboard.',
@@ -34,6 +34,7 @@ import {
type SingleView,
uniMap,
} from '@blocksuite/data-view';
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
import { IS_MOBILE } from '@blocksuite/global/env';
import { Rect } from '@blocksuite/global/gfx';
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
config
);
});
this.std.provider
.getAll(CalendarExternalSourceProvider)
.forEach(source => {
dataSource.serviceSet(
CalendarExternalSourceProvider(source.id),
source
);
});
});
const id = currentViewStorage.getCurrentView(this.model.id);
if (id && dataSource.viewManager.viewGet(id)) {
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
calendar: [
widgetPresets.tools.filter,
widgetPresets.tools.search,
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
});
private readonly viewSelection$ = computed(() => {
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
headerWidget: this.headerWidget,
onDrag: this.onDrag,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
export const databaseBlockViews: ViewMeta[] = [
viewPresets.tableViewMeta,
viewPresets.kanbanViewMeta,
viewPresets.calendarViewMeta,
];
export const databaseBlockViewMap = Object.fromEntries(
@@ -121,6 +121,38 @@ export const updateBlockType: Command<
}
return next({ updatedBlocks: [newModel] });
};
const transformToLatex: Command<{}, { updatedBlocks: BlockModel[] }> = (
_,
next
) => {
if (flavour !== 'affine:latex') return;
const newModels: BlockModel[] = [];
blockModels.forEach(model => {
if (
!matchModels(model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
) {
return;
}
const latex = model.text?.toString() ?? '';
const newId = transformModel(model, 'affine:latex', { latex });
if (!newId) {
return;
}
const newModel = doc.getModelById(newId);
if (newModel) {
newModels.push(newModel);
}
});
if (newModels.length === 0) return;
return next({ updatedBlocks: newModels });
};
const focusText: Command<{ updatedBlocks: BlockModel[] }> = (ctx, next) => {
const { updatedBlocks } = ctx;
@@ -185,6 +217,27 @@ export const updateBlockType: Command<
});
return next();
};
const selectBlocks: Command<{ updatedBlocks: BlockModel[] }> = (
ctx,
next
) => {
const { updatedBlocks } = ctx;
if (!updatedBlocks || updatedBlocks.length === 0) {
return false;
}
requestAnimationFrame(() => {
host.selection.setGroup(
'note',
updatedBlocks.map(model =>
host.selection.create(BlockSelection, {
blockId: model.id,
})
)
);
});
return next();
};
const [result, resultCtx] = std.command
.chain()
@@ -196,6 +249,7 @@ export const updateBlockType: Command<
.try<{ updatedBlocks: BlockModel[] }>(chain => [
chain.pipe(mergeToCode),
chain.pipe(appendDivider),
chain.pipe(transformToLatex),
chain.pipe((_, next) => {
const newModels: BlockModel[] = [];
blockModels.forEach(model => {
@@ -227,6 +281,14 @@ export const updateBlockType: Command<
])
// focus
.try(chain => [
chain
.pipe((_, next) => {
if (flavour === 'affine:latex') {
return next();
}
return false;
})
.pipe(selectBlocks),
chain.pipe((_, next) => {
if (['affine:code', 'affine:divider'].includes(flavour)) {
return next();
@@ -30,6 +30,7 @@
"@blocksuite/affine-gfx-pointer": "workspace:*",
"@blocksuite/affine-gfx-shape": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-inline-latex": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
@@ -15,6 +15,7 @@ import {
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
import { toast } from '@blocksuite/affine-components/toast';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
import {
deleteTextCommand,
formatBlockCommand,
@@ -61,6 +62,7 @@ import {
DeleteIcon,
DuplicateIcon,
LinkedPageIcon,
TeXIcon,
} from '@blocksuite/icons/lit';
import {
type BlockComponent,
@@ -199,9 +201,9 @@ const alignActionGroup = {
const inlineTextActionGroup = {
id: 'b.inline-text',
when: ({ chain }) => isFormatSupported(chain).run()[0],
actions: textFormatConfigs.map(
actions: textFormatConfigs.flatMap(
({ id, name, action, activeWhen, icon }, score) => {
return {
const textAction: ToolbarAction = {
id,
icon,
score,
@@ -209,6 +211,28 @@ const inlineTextActionGroup = {
run: ({ host }) => action(host),
active: ({ host }) => activeWhen(host),
};
if (id !== 'underline') {
return [textAction];
}
return [
textAction,
{
id: 'inline-latex',
icon: TeXIcon(),
score: score + 0.5,
tooltip: 'Inline Equation',
run: ({ host }) => {
host.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(insertInlineLatex)
.run();
},
active: () => false,
},
];
}
),
} as const satisfies ToolbarActionGroup;
@@ -27,6 +27,7 @@
{ "path": "../../gfx/pointer" },
{ "path": "../../gfx/shape" },
{ "path": "../../gfx/text" },
{ "path": "../../inlines/latex" },
{ "path": "../../inlines/preset" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.inputRef.select();
if (!this.data.disableAutoFocus) {
this.inputRef.select();
}
});
});
}
@@ -223,6 +225,7 @@ export const menuInputItems = {
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
disableAutoFocus?: boolean;
class?: string;
style?: Readonly<StyleInfo>;
}) =>
@@ -237,6 +240,7 @@ export const menuInputItems = {
onComplete: config.onComplete,
onChange: config.onChange,
onBlur: config.onBlur,
disableAutoFocus: config.disableAutoFocus,
};
const style = styleMap({
display: 'flex',
@@ -111,8 +111,10 @@ export class MenuComponent
}
const onBack = this.menu.options.title?.onBack;
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
this.menu.close();
onBack(this.menu);
const result = onBack(this.menu);
if (result !== false) {
this.menu.close();
}
return;
}
if (e.key === 'Enter' && !e.isComposing) {
@@ -214,8 +216,10 @@ export class MenuComponent
${title.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
const result = title.onBack?.(this.menu);
if (result !== false) {
this.menu.close();
}
}}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
@@ -15,7 +15,7 @@ export type MenuOptions = {
onClose?: () => void;
title?: {
text: string;
onBack?: (menu: Menu) => void;
onBack?: (menu: Menu) => boolean | void;
onClose?: () => void;
postfix?: () => TemplateResult;
};
@@ -0,0 +1,371 @@
import { describe, expect, it } from 'vitest';
import {
type CalendarEntry,
createCalendarMonthLayout,
getCalendarDayContentSlots,
getCalendarVisibleMonthRange,
} from '../view-presets/calendar/index.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
describe('calendar month layout', () => {
it('buckets single day entries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('splits range external entries across weeks', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Trip',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 6, span: 1 },
{ weekIndex: 2, startIndex: 0, span: 3 },
]);
});
it('treats all-day external midnight end as exclusive', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'All day',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
allDay: true,
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('treats row midnight end date as inclusive', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 2, startIndex: 5, span: 2 },
]);
});
it('clips range entries to visible month range', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Long trip',
startAt: day('2026-04-01'),
endAt: day('2026-06-30'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments[0]).toMatchObject({
weekIndex: 0,
startIndex: 0,
span: 7,
});
expect(layout.segments.at(-1)).toMatchObject({
weekIndex: layout.weeks.length - 1,
startIndex: 0,
span: 7,
});
});
it('pads month view to full weeks', () => {
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [],
});
expect(new Date(range.from).getDay()).toBe(0);
expect(new Date(range.to).getDay()).toBe(6);
expect(layout.days).toHaveLength(layout.weeks.length * 7);
});
it('keeps day buckets on local midnight across DST boundaries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'DST task',
startAt: day('2026-03-09'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(
layout.days.every(item => {
const date = new Date(item.date);
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
})
).toBe(true);
expect(
layout.days.find(item => item.date === day('2026-03-09'))?.entries
).toEqual([entry]);
});
it('keeps range segment offsets across DST boundaries', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'DST range',
startAt: day('2026-03-09'),
endAt: new Date('2026-03-10T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 1, span: 2 },
]);
});
it('keeps all same-day entries in the day bucket', () => {
const entries = Array.from(
{ length: 4 },
(_, index) =>
({
kind: 'row',
id: `database:row-${index}`,
sourceId: 'database',
rowId: `row-${index}`,
title: `Task ${index}`,
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
}) satisfies CalendarEntry
);
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toHaveLength(4);
});
it('assigns each overlapping range segment to its own slot', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:full-${index}`,
sourceId: 'workspace-calendar',
externalId: `full-${index}`,
title: `Full ${index}`,
startAt: day('2026-05-15'),
endAt: new Date('2026-05-17T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'external',
id: 'external:short',
sourceId: 'workspace-calendar',
externalId: 'short',
title: 'Short',
startAt: day('2026-05-18'),
endAt: new Date('2026-05-19T12:00:00').getTime(),
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
expect(getCalendarDayContentSlots(may15)).toBe(3);
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
expect(getCalendarDayContentSlots(may18)).toBe(1);
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
});
it('counts segment and same-day slots for drag preview placement', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:range-${index}`,
sourceId: 'workspace-calendar',
externalId: `range-${index}`,
title: `Range ${index}`,
startAt: day('2026-05-08'),
endAt: new Date('2026-05-09T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'row',
id: 'database:moving',
sourceId: 'database',
rowId: 'moving',
title: 'Moving',
startAt: day('2026-05-06'),
endAt: new Date('2026-05-08T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
},
{
kind: 'row',
id: 'database:single',
sourceId: 'database',
rowId: 'single',
title: 'Single',
startAt: day('2026-05-08'),
cardProperties: [],
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
});
it('splits row range entries across weeks with continuation metadata', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Project',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{
weekIndex: 1,
startIndex: 6,
span: 1,
startsBeforeWeek: false,
endsAfterWeek: true,
},
{
weekIndex: 2,
startIndex: 0,
span: 3,
startsBeforeWeek: true,
endsAfterWeek: false,
},
]);
});
it('skips range entries completely outside the visible month range', () => {
const entry = {
kind: 'external',
id: 'external:outside',
sourceId: 'workspace-calendar',
externalId: 'outside',
title: 'Outside',
startAt: day('2026-06-10'),
endAt: day('2026-06-12'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toEqual([]);
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
});
});
@@ -0,0 +1,812 @@
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import { signal } from '@preact/signals-core';
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from '../core/data-source/base.js';
import {
CalendarSingleView,
type CalendarStoredViewData,
calendarViewModel,
} from '../view-presets/calendar/index.js';
import {
formatEntryTime,
openCalendarEntry,
} from '../view-presets/calendar/pc/actions.js';
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
import { viewConverts } from '../view-presets/convert.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
const createCalendarView = (options?: {
startColumnId?: string;
endColumnId?: string;
datePropertyType?: string;
rows?: string[];
filterValue?: string;
titleValue?: unknown;
linkedDocTitles?: Record<string, string>;
visiblePropertyIds?: string[];
externalFactories?: Map<unknown, unknown>;
}) => {
const rows = signal(options?.rows ?? ['row-1']);
const columns = signal(['title', 'date', 'end-date', 'status']);
const viewData = signal<CalendarStoredViewData>({
id: 'view-1',
name: 'Calendar',
mode: 'calendar',
filter: options?.filterValue
? {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: { type: 'ref', name: 'status' },
function: 'is',
args: [{ type: 'literal', value: options.filterValue }],
},
],
}
: {
type: 'group',
op: 'and',
conditions: [],
},
date: {
startColumnId: options?.startColumnId,
endColumnId: options?.endColumnId,
},
card: {
titleColumnId: 'title',
visiblePropertyIds: options?.visiblePropertyIds ?? [],
},
sources: {
workspaceCalendar: {
enabled: true,
},
},
});
const values = new Map<string, unknown>([
['row-1:date', day('2026-05-15')],
['row-1:end-date', day('2026-05-17')],
['row-1:status', 'Done'],
['row-1:title', options?.titleValue ?? 'Task'],
['row-2:date', day('2026-05-16')],
['row-2:end-date', day('2026-05-14')],
['row-2:status', 'Todo'],
['row-2:title', 'Hidden'],
]);
const types = new Map<string, string>([
['title', 'title'],
['date', options?.datePropertyType ?? 'date'],
['end-date', 'date'],
['status', 'text'],
]);
const dataSource = {
rows$: rows,
properties$: columns,
readonly$: signal(false),
featureFlags$: signal({ enable_table_virtual_scroll: false }),
provider: {
getAll: () => options?.externalFactories ?? new Map(),
},
viewDataGet: () => viewData.value,
viewDataUpdate: (
_id: string,
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
) => {
viewData.value = { ...viewData.value, ...updater(viewData.value) };
},
cellValueGet: (rowId: string, propertyId: string) =>
values.get(`${rowId}:${propertyId}`),
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
values.set(`${rowId}:${propertyId}`, value);
},
rowAdd: () => {
const rowId = `row-${rows.value.length + 1}`;
rows.value = [...rows.value, rowId];
return rowId;
},
propertyTypeGet: (propertyId: string) => types.get(propertyId),
propertyNameGet: (propertyId: string) => propertyId,
propertyDataGet: () => ({}),
propertyReadonlyGet: () => false,
serviceGet: (key: unknown) => {
if (key !== DocDisplayMetaProvider) {
return null;
}
return {
title: (pageId: string, referenceInfo?: { title?: string }) =>
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
};
},
propertyMetaGet: (type: string) => ({
type,
config: {
rawValue: {
toJson: ({ value }: { value: unknown }) => {
const deltas =
typeof value === 'object' && value != null && 'deltas$' in value
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
: undefined;
if (!Array.isArray(deltas)) {
return value;
}
return deltas
.map(delta => {
const item = delta as {
insert?: unknown;
attributes?: {
reference?: {
type?: string;
pageId?: unknown;
};
};
};
const pageId = item.attributes?.reference?.pageId;
if (
item.attributes?.reference?.type === 'LinkedPage' &&
typeof pageId === 'string'
) {
return (
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
);
}
return item.insert ?? '';
})
.join('');
},
fromJson: ({ value }: { value: unknown }) => value,
toString: ({ value }: { value: unknown }) =>
typeof value === 'string' ? value : '',
},
jsonValue: {
schema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
isEmpty: () => false,
type: () => undefined,
},
},
renderer: {},
}),
propertyAdd: () => {
columns.value = [...columns.value, 'created-date'];
types.set('created-date', 'date');
return 'created-date';
},
propertyCanDelete: () => true,
propertyCanDuplicate: () => true,
propertyTypeCanSet: () => true,
} as unknown as DataSource;
const manager = {
dataSource,
readonly$: signal(false),
};
return {
view: new CalendarSingleView(manager as any, 'view-1'),
viewData,
values,
types,
columns,
};
};
describe('CalendarSingleView', () => {
it('creates default view data without selecting a start date', () => {
const data = calendarViewModel.model.defaultData({
dataSource: {
properties$: signal(['title', 'date']),
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
},
} as any);
expect(data.date).toEqual({});
expect(data.card).toEqual({
titleColumnId: 'title',
visiblePropertyIds: [],
});
});
it('enters setup state without a start date property', () => {
const { view } = createCalendarView();
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state when start date column is not date', () => {
const { view } = createCalendarView({
startColumnId: 'date',
datePropertyType: 'text',
});
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state after date property deletion', () => {
const { view, columns } = createCalendarView({ startColumnId: 'date' });
columns.value = ['title', 'status'];
expect(view.dateMapping$.value.status).toBe('setup');
});
it('creates row entries after filtering rows', () => {
const { view } = createCalendarView({
startColumnId: 'date',
rows: ['row-1', 'row-2'],
filterValue: 'Done',
});
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
});
it('updates entry date after row date value changes', () => {
const { view, values } = createCalendarView({ startColumnId: 'date' });
values.set('row-1:date', day('2026-05-20'));
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
});
it('creates row range entries and falls back when end date is invalid', () => {
const { view } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
rows: ['row-1', 'row-2'],
});
expect(
view.rowEntries$.value.map(entry => [
entry.rowId,
entry.startAt,
entry.endAt,
])
).toEqual([
['row-1', day('2026-05-15'), day('2026-05-17')],
['row-2', day('2026-05-16'), undefined],
]);
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
});
it('moves row range while preserving duration', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.moveRowToDate('row-1', day('2026-05-20'));
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
});
it('resizes row range without crossing start and end', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
});
it('creates a row with default filter values and target date', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createRowOnDate(day('2026-05-25'));
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(view.emptyMonthHintDismissed$.value).toBe(true);
});
it('creates a dated linked-doc row', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
const title = values.get('row-2:title') as
| { toDelta?: () => unknown[] }
| undefined;
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(title?.toDelta?.()).toEqual([
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
]);
});
it('dismisses the empty month hint on the current calendar view', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
expect(view.emptyMonthHintDismissed$.value).toBe(false);
view.dismissEmptyMonthHint();
expect(view.emptyMonthHintDismissed$.value).toBe(true);
expect('ui' in viewData.value && viewData.value.ui).toEqual({
emptyMonthHintDismissed: true,
});
});
it('updates workspace calendar settings when legacy view data has no sources', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
viewData.value = {
...viewData.value,
sources: undefined as unknown as CalendarStoredViewData['sources'],
};
view.setWorkspaceCalendarEnabled(false);
expect(viewData.value.sources.workspaceCalendar).toEqual({
enabled: false,
});
});
it('enters setup state when legacy view data has no date config', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
viewData.value = {
...viewData.value,
date: undefined as unknown as CalendarStoredViewData['date'],
};
expect(view.dateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
expect(view.endDateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
});
it('generates card properties from visible property ids', () => {
const { view } = createCalendarView({
startColumnId: 'date',
visiblePropertyIds: ['status'],
});
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
{
propertyId: 'status',
value: 'Done',
},
]);
});
it('parses single linked doc id from title cell', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Linked doc title',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Linked doc title', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
});
it('uses normal title text for multiple linked doc titles', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc 1',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: 'Doc 2',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
});
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
});
it('merges linked doc placeholders with the following plain title text', () => {
const { view } = createCalendarView({
startColumnId: 'date',
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: { type: 'LinkedPage', pageId: 'doc-1' },
},
},
{ insert: 'How to use folder and Tags' },
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'How to use folder and Tags', linkedDoc: true },
]);
});
it('updates date mapping through setup APIs', () => {
const { view, viewData, values } = createCalendarView({
startColumnId: 'date',
});
view.moveRowToDate('row-1', day('2026-05-21'));
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
view.setDateColumn('date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'date'
);
expect(view.createDateColumn()).toBe('created-date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'created-date'
);
});
it('aggregates external source entries without mutating view data', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const anotherExternalEntry = {
kind: 'external',
id: 'external:2',
sourceId: 'another-source',
externalId: '2',
title: 'Another external',
startAt: day('2026-05-16'),
canResizeRange: false,
} as const;
const { view, viewData } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'another-source',
{
create: () => ({
id: 'another-source',
getEntries: () => Promise.resolve([anotherExternalEntry]),
}),
},
],
]),
});
const viewDataBefore = JSON.stringify(viewData.value);
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry, anotherExternalEntry]);
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
});
it('keeps successful external entries when another source fails', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'failing-source',
{
create: () => ({
id: 'failing-source',
getEntries: () => Promise.reject(new Error('denied')),
}),
},
],
]),
});
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry]);
});
it('does not let stale external entry loads overwrite newer entries', async () => {
const oldEntry = {
kind: 'external',
id: 'external:old',
sourceId: 'source',
externalId: 'old',
title: 'Old',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const newEntry = {
kind: 'external',
id: 'external:new',
sourceId: 'source',
externalId: 'new',
title: 'New',
startAt: day('2026-06-15'),
canResizeRange: false,
} as const;
let resolveOld!: (entries: [typeof oldEntry]) => void;
let resolveNew!: (entries: [typeof newEntry]) => void;
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
resolveOld = resolve;
});
const newRequest = new Promise<[typeof newEntry]>(resolve => {
resolveNew = resolve;
});
const getEntries = vi
.fn()
.mockReturnValueOnce(oldRequest)
.mockReturnValueOnce(newRequest);
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries,
}),
},
],
]),
});
const firstLoad = view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
});
const secondLoad = view.loadExternalEntries({
from: day('2026-06-01'),
to: day('2026-06-30'),
});
resolveNew([newEntry]);
await expect(secondLoad).resolves.toEqual([newEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
resolveOld([oldEntry]);
await expect(firstLoad).resolves.toEqual([oldEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
});
});
describe('calendar entry actions', () => {
it('formats external event popover time ranges with end time', () => {
const label = formatEntryTime({
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Planning',
startAt: new Date('2026-05-15T10:00:00').getTime(),
endAt: new Date('2026-05-15T11:00:00').getTime(),
canResizeRange: false,
});
expect(label).toContain(' - ');
expect(label).toContain('2026');
});
it('opens row entries through the detail panel hook', () => {
const openDetailPanel = vi.fn();
const { view } = createCalendarView({ startColumnId: 'date' });
const target = {} as HTMLElement;
openCalendarEntry(
{ openDetailPanel } as any,
view,
{
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Doc',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
},
target
);
expect(openDetailPanel).toHaveBeenCalledWith(
expect.objectContaining({ view, rowId: 'row-1' })
);
});
});
describe('calendar view converts', () => {
it('converts header/card semantics without date mapping', () => {
const tableToCalendar = viewConverts.find(
convert => convert.from === 'table' && convert.to === 'calendar'
);
const calendarToKanban = viewConverts.find(
convert => convert.from === 'calendar' && convert.to === 'kanban'
);
const filter = { type: 'group', op: 'and', conditions: [] } as const;
const sort = { columns: [] };
const header = { titleColumn: 'title' };
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: [] },
});
expect(
calendarToKanban?.convert({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
date: { startColumnId: 'date' },
} as any)
).toEqual({ filter, sort, header });
});
});
describe('calendar dnd payload', () => {
it('reads calendar entry payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
})
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
});
it('normalizes affine doc entities for future document drops', () => {
expect(
getCalendarDndEntity({
entity: { type: 'doc', id: 'doc-1' },
})
).toEqual({ type: 'doc', docId: 'doc-1' });
});
it('reads document payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
).toEqual({ type: 'doc', docId: 'doc-1' });
});
});
@@ -8,6 +8,7 @@ import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type Clipboard,
type DndController,
type EventName,
ShadowlessElement,
type UIEventHandler,
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
export type DataViewRendererConfig = {
clipboard: Clipboard;
dnd?: DndController;
onDrag?: (evt: MouseEvent, id: string) => () => void;
notification: {
toast: (message: string) => void;
@@ -2,15 +2,10 @@ import {
dropdownSubMenuMiddleware,
menu,
type MenuConfig,
type MenuOptions,
popMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit';
@@ -260,188 +255,183 @@ export class GroupSetting extends SignalWatcher(
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
}
export const selectGroupByProperty = (
export const buildGroupSelectItems = (
group: GroupTrait,
ops?: {
onSelect?: (id?: string) => void;
onClose?: () => void;
onBack?: () => void;
}
): MenuOptions => {
onSelect: (id?: string) => void
): MenuConfig[] => {
const view = group.view;
return {
onClose: ops?.onClose,
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
items: [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property => {
return menu.action({
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(property.id);
ops?.onSelect?.(property.id);
},
});
}),
}),
menu.group({
items: [
return [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property =>
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(undefined);
ops?.onSelect?.();
group.changeGroup(property.id);
onSelect(property.id);
return false;
},
}),
],
}),
],
};
})
),
}),
menu.group({
items: [
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
select: () => {
group.changeGroup(undefined);
onSelect(undefined);
return false;
},
}),
],
}),
];
};
export const popSelectGroupByProperty = (
target: PopupTarget,
export const buildGroupSettingItems = (
group: GroupTrait,
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
middleware?: Array<Middleware | null | undefined | false>
) => {
const handler = popMenu(target, {
options: selectGroupByProperty(group, ops),
middleware,
});
handler.menu.menuElement.style.minHeight = '550px';
};
export const popGroupSetting = (
target: PopupTarget,
group: GroupTrait,
onBack: () => void,
onClose?: () => void,
middleware?: Array<Middleware | null | undefined | false>
) => {
onGroupByClick: () => void,
onGroupRemoved?: () => void
): MenuConfig[] => {
const view = group.view;
const gProp = group.property$.value;
if (!gProp) return;
if (!gProp) return [];
const type = gProp.type$.value;
if (!type) return;
if (!type) return [];
const icon = gProp.icon;
const menuHandler = popMenu(target, {
options: {
title: {
text: 'Group',
onBack,
onClose,
},
items: [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
const subHandler = popMenu(target, {
options: selectGroupByProperty(group, {
onSelect: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onBack: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onClose,
}),
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
return [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
onGroupByClick();
return false;
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name === key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
@@ -462,179 +452,118 @@ export const popGroupSetting = (
return false;
},
})
)
),
],
},
}),
]),
],
}),
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config
.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name ===
key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.sortAsc$.value
? 'Oldest first'
: 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menuObj => html`
<data-view-group-setting
@mouseenter=${() => menuObj.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menu => html`
<data-view-group-setting
@mouseenter=${() => menu.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
return false;
},
}),
],
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
onGroupRemoved?.();
return false;
},
}),
],
},
middleware,
});
menuHandler.menu.menuElement.style.minHeight = '550px';
}),
];
};
@@ -0,0 +1,605 @@
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import { type DeltaInsert, Text } from '@blocksuite/store';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { Doc } from 'yjs';
import { evalFilter } from '../../core/filter/eval.js';
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
import type { FilterGroup } from '../../core/filter/types.js';
import { emptyFilterGroup } from '../../core/filter/utils.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import { type Row, RowBase } from '../../core/view-manager/row.js';
import {
type SingleView,
SingleViewBase,
} from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { getCalendarExternalSources } from './source.js';
import type {
CalendarEntry,
CalendarEntryRange,
CalendarExternalEntry,
CalendarExternalSource,
CalendarRowEntry,
CalendarStoredViewData,
CalendarTitleSegment,
} from './types.js';
export type CalendarDateMapping =
| {
status: 'ready';
propertyId: string;
}
| {
status: 'setup';
propertyId?: string;
};
const getStartColumnId = (data?: CalendarStoredViewData) =>
data?.date?.startColumnId;
const getEndColumnId = (data?: CalendarStoredViewData) => {
return data?.date?.endColumnId;
};
const getDateData = (data: CalendarStoredViewData) => ({
...data.date,
startColumnId: getStartColumnId(data),
});
const getCardData = (data?: CalendarStoredViewData) => {
if (data) {
return data.card;
}
return {
visiblePropertyIds: [],
};
};
const toTimestamp = (date: number | Date) =>
date instanceof Date ? date.getTime() : date;
const isValidTimestamp = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const createLinkedDocTitle = (docId: string) => {
const text = new Text<AffineTextAttributes>();
new Doc().getMap('root').set('text', text.yText);
text.applyDelta([
{
insert: ' ',
attributes: { reference: { type: 'LinkedPage', pageId: docId } },
},
] satisfies DeltaInsert<AffineTextAttributes>[]);
return text;
};
const getTitleDeltas = (value: unknown) =>
typeof value === 'object' && value != null && 'deltas$' in value
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
: undefined;
const getTitleSegments = (
value: unknown,
title: string,
getLinkedDocTitle?: (pageId: string, title?: string) => string | undefined
): CalendarTitleSegment[] | undefined => {
const deltas = getTitleDeltas(value);
if (!Array.isArray(deltas)) {
return;
}
const segments = deltas.flatMap(delta => {
const item = delta as {
insert?: unknown;
attributes?: {
reference?: {
type?: string;
pageId?: unknown;
title?: unknown;
};
};
};
const linkedDoc =
item.attributes?.reference?.type === 'LinkedPage' &&
typeof item.attributes.reference.pageId === 'string';
const referenceTitle = item.attributes?.reference?.title;
const resolvedLinkedDocTitle =
linkedDoc && typeof item.attributes?.reference?.pageId === 'string'
? getLinkedDocTitle?.(
item.attributes.reference.pageId,
typeof referenceTitle === 'string' ? referenceTitle : undefined
)
: undefined;
const text =
resolvedLinkedDocTitle ||
(linkedDoc && typeof referenceTitle === 'string' && referenceTitle
? referenceTitle
: typeof item.insert === 'string'
? item.insert.trim()
: '');
if (linkedDoc) {
return {
text,
linkedDoc,
};
}
if (!text) {
return [];
}
return {
text,
};
});
const normalizedSegments = segments.reduce<CalendarTitleSegment[]>(
(result, segment) => {
const previous = result.at(-1);
if (
previous?.linkedDoc &&
!previous.text &&
!segment.linkedDoc &&
segment.text
) {
previous.text = segment.text;
return result;
}
result.push(segment);
return result;
},
[]
);
if (!normalizedSegments.some(segment => segment.linkedDoc)) {
return;
}
if (!normalizedSegments.some(segment => segment.text)) {
return title
? [...normalizedSegments, { text: title }]
: normalizedSegments;
}
return normalizedSegments;
};
export class CalendarSingleView extends SingleViewBase<CalendarStoredViewData> {
private readonly externalEntries$ = signal<CalendarExternalEntry[]>([]);
private externalEntriesRequestId = 0;
propertiesRaw$ = computed(() => {
return this.dataSource.properties$.value.map(id =>
this.propertyGetOrCreate(id)
);
});
properties$ = this.propertiesRaw$;
detailProperties$ = computed(() => {
return this.propertiesRaw$.value.filter(
property => property.type$.value !== 'title'
);
});
private readonly filter$ = computed(() => {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private readonly sortList$ = computed(() => {
return this.data$.value?.sort;
});
emptyMonthHintDismissed$ = computed(() => {
return this.data$.value?.ui?.emptyMonthHintDismissed ?? false;
});
private readonly sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => ({
sort: {
...data.sort,
...sortList,
},
}));
},
})
);
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
filterSet: (filter: FilterGroup) => {
this.dataUpdate(() => ({ filter }));
},
})
);
mainProperties$ = computed(() => {
const card = getCardData(this.data$.value);
return {
titleColumn:
card.titleColumnId ??
this.propertiesRaw$.value.find(
property => property.type$.value === 'title'
)?.id,
};
});
readonly$ = computed(() => {
return this.manager.readonly$.value;
});
dateProperties$ = computed(() => {
return this.propertiesRaw$.value.filter(
property => property.type$.value === 'date'
);
});
dateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
const propertyId = getStartColumnId(this.data$.value);
if (
propertyId &&
this.dataSource.properties$.value.includes(propertyId) &&
this.dataSource.propertyTypeGet(propertyId) === 'date'
) {
return {
status: 'ready',
propertyId,
};
}
return {
status: 'setup',
propertyId,
};
});
startDateMapping$ = this.dateMapping$;
endDateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
const propertyId = getEndColumnId(this.data$.value);
if (
propertyId &&
this.dataSource.properties$.value.includes(propertyId) &&
this.dataSource.propertyTypeGet(propertyId) === 'date'
) {
return {
status: 'ready',
propertyId,
};
}
return {
status: 'setup',
propertyId,
};
});
private readonly visibleCardProperties$ = computed(() => {
const card = getCardData(this.data$.value);
const visiblePropertyIds = card.visiblePropertyIds ?? [];
const titleColumn = card.titleColumnId;
return visiblePropertyIds
.filter(propertyId => propertyId !== titleColumn)
.map(propertyId => this.propertyGetOrCreate(propertyId));
});
rowEntries$ = computed<CalendarRowEntry[]>(() => {
const mapping = this.dateMapping$.value;
if (mapping.status !== 'ready') {
return [];
}
const endMapping = this.endDateMapping$.value;
return this.rows$.value.flatMap(row => {
const startAt = this.cellGetOrCreate(row.rowId, mapping.propertyId)
.jsonValue$.value;
if (!isValidTimestamp(startAt)) {
return [];
}
const endAt =
endMapping.status === 'ready'
? this.cellGetOrCreate(row.rowId, endMapping.propertyId).jsonValue$
.value
: undefined;
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
const titleCell = this.cellGetOrCreate(row.rowId, titleColumn);
const jsonTitle = titleCell.jsonValue$.value;
const title =
(typeof jsonTitle === 'string'
? jsonTitle
: titleCell.stringValue$.value) ?? '';
const docDisplayMeta = this.manager.dataSource.serviceGet(
DocDisplayMetaProvider
);
const resolveLinkedDocTitle = (pageId: string, title?: string) =>
docDisplayMeta?.title(pageId, { title }).value;
const titleSegments = getTitleSegments(
titleCell.value$.value,
title,
resolveLinkedDocTitle
);
const cardProperties = this.visibleCardProperties$.value.flatMap(
property => {
const cell = this.cellGetOrCreate(row.rowId, property.id);
const value = cell.stringValue$.value;
if (!value) {
return [];
}
return {
propertyId: property.id,
value,
};
}
);
return {
kind: 'row',
id: `database:${row.rowId}`,
sourceId: 'database',
rowId: row.rowId,
title,
startAt,
endAt: isValidTimestamp(endAt) && endAt >= startAt ? endAt : undefined,
titleSegments,
cardProperties,
canResizeRange: endMapping.status === 'ready' && !this.readonly$.value,
} satisfies CalendarRowEntry;
});
});
entries$ = computed<CalendarEntry[]>(() => {
return [...this.rowEntries$.value, ...this.externalEntries$.value];
});
externalSources$ = computed<CalendarExternalSource[]>(() => {
const viewData = this.data$.value;
if (!viewData) {
return [];
}
return getCalendarExternalSources(this.dataSource, viewData);
});
get type(): string {
return this.data$.value?.mode ?? 'calendar';
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
isShow(rowId: string): boolean {
if (this.filter$.value.conditions.length) {
const rowMap = Object.fromEntries(
this.propertiesRaw$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])
);
return evalFilter(this.filter$.value, rowMap);
}
return true;
}
override rowsMapping(rows: Row[]) {
return this.sortManager.sort(super.rowsMapping(rows));
}
propertyGetOrCreate(propertyId: string): CalendarProperty {
return new CalendarProperty(this, propertyId);
}
override rowGetOrCreate(rowId: string): CalendarRow {
return new CalendarRow(this, rowId);
}
setStartDateColumn(propertyId: string) {
this.dataUpdate(data => ({
date: {
...getDateData(data),
startColumnId: propertyId,
},
}));
}
setDateColumn(propertyId: string) {
this.setStartDateColumn(propertyId);
}
setEndDateColumn(propertyId: string | undefined) {
this.dataUpdate(data => ({
date: {
...getDateData(data),
endColumnId: propertyId,
},
}));
}
setWorkspaceCalendarEnabled(enabled: boolean) {
this.dataUpdate(data => ({
sources: {
...data.sources,
workspaceCalendar: {
...(data.sources?.workspaceCalendar ?? { enabled: true }),
enabled,
},
},
}));
}
setWorkspaceCalendarSubscriptionIds(subscriptionIds?: string[]) {
this.dataUpdate(data => ({
sources: {
...data.sources,
workspaceCalendar: {
...(data.sources?.workspaceCalendar ?? { enabled: true }),
subscriptionIds,
},
},
}));
}
dismissEmptyMonthHint() {
this.dataUpdate(data => ({
ui: {
...data.ui,
emptyMonthHintDismissed: true,
},
}));
}
getDocDisplayTitle(docId: string) {
return (
this.manager.dataSource.serviceGet(DocDisplayMetaProvider)?.title(docId)
.value ?? 'Untitled'
);
}
createStartDateColumn() {
const id = this.propertyAdd('end', {
type: 'date',
name: 'Date',
});
if (id) {
this.setStartDateColumn(id);
}
return id;
}
createDateColumn() {
return this.createStartDateColumn();
}
createEndDateColumn() {
const id = this.propertyAdd('end', {
type: 'date',
name: 'End Date',
});
if (id) {
this.setEndDateColumn(id);
}
return id;
}
createRowOnDate(date: number | Date) {
const mapping = this.startDateMapping$.value;
if (mapping.status !== 'ready') {
return;
}
const rowId = this.rowAdd('end');
const filter = this.filter$.value;
if (filter.conditions.length > 0) {
const defaultValues = generateDefaultValues(filter, this.vars$.value);
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
const property = this.propertyGetOrCreate(propertyId);
const propertyMeta = property.meta$.value;
if (propertyMeta) {
const value = fromJson(propertyMeta.config, {
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,
});
this.cellGetOrCreate(rowId, propertyId).valueSet(value);
}
});
}
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(
toTimestamp(date)
);
this.dismissEmptyMonthHint();
return rowId;
}
createLinkedDocRowOnDate(date: number | Date, docId: string) {
const rowId = this.createRowOnDate(date);
if (!rowId) return;
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
this.cellGetOrCreate(rowId, titleColumn).valueSet(
createLinkedDocTitle(docId)
);
return rowId;
}
moveRowToDate(rowId: string, date: number | Date) {
const mapping = this.startDateMapping$.value;
if (mapping.status !== 'ready') {
return;
}
const value = toTimestamp(date);
const oldStartAt = this.cellGetOrCreate(rowId, mapping.propertyId)
.jsonValue$.value;
const endMapping = this.endDateMapping$.value;
if (endMapping.status === 'ready' && isValidTimestamp(oldStartAt)) {
const oldEndAt = this.cellGetOrCreate(rowId, endMapping.propertyId)
.jsonValue$.value;
if (isValidTimestamp(oldEndAt) && oldEndAt >= oldStartAt) {
this.cellGetOrCreate(rowId, endMapping.propertyId).jsonValueSet(
value + (oldEndAt - oldStartAt)
);
}
}
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(value);
}
resizeRowRange(rowId: string, edge: 'start' | 'end', date: number | Date) {
const startMapping = this.startDateMapping$.value;
const endMapping = this.endDateMapping$.value;
if (startMapping.status !== 'ready' || endMapping.status !== 'ready') {
return;
}
const startCell = this.cellGetOrCreate(rowId, startMapping.propertyId);
const endCell = this.cellGetOrCreate(rowId, endMapping.propertyId);
const startAt = startCell.jsonValue$.value;
const endAt = endCell.jsonValue$.value;
if (!isValidTimestamp(startAt) || !isValidTimestamp(endAt)) {
return;
}
const value = toTimestamp(date);
if (edge === 'start') {
startCell.jsonValueSet(Math.min(value, endAt));
} else {
endCell.jsonValueSet(Math.max(value, startAt));
}
}
async loadExternalEntries(range: CalendarEntryRange) {
const requestId = ++this.externalEntriesRequestId;
const viewData = this.data$.value;
if (!viewData) {
this.externalEntries$.value = [];
return [];
}
const results = await Promise.allSettled(
this.externalSources$.value.map(source =>
Promise.resolve(source.getEntries(range))
)
);
const entries = results.flatMap(result =>
result.status === 'fulfilled' ? result.value : []
);
if (requestId === this.externalEntriesRequestId) {
this.externalEntries$.value = entries;
}
return entries;
}
}
export class CalendarProperty extends PropertyBase {
hide$ = computed(() => false);
constructor(view: CalendarSingleView, propertyId: string) {
super(view as SingleView, propertyId);
}
hideSet(_hide: boolean): void {}
move(_position: InsertToPosition): void {}
}
export class CalendarRow extends RowBase {
constructor(
readonly calendarView: CalendarSingleView,
rowId: string
) {
super(calendarView, rowId);
}
}
@@ -0,0 +1,34 @@
import { viewType } from '../../core/view/data-view.js';
import { CalendarSingleView } from './calendar-view-manager.js';
import type { CalendarViewData } from './types.js';
export const calendarViewType = viewType('calendar');
export const calendarViewModel = calendarViewType.createModel<CalendarViewData>(
{
defaultName: 'Calendar View',
dataViewManager: CalendarSingleView,
defaultData: viewManager => {
return {
filter: {
type: 'group',
op: 'and',
conditions: [],
},
date: {},
card: {
titleColumnId: viewManager.dataSource.properties$.value.find(
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
),
visiblePropertyIds: [],
},
sources: {
workspaceCalendar: {
enabled: true,
},
},
ui: {},
};
},
}
);
@@ -0,0 +1,5 @@
import { pcEffects } from './pc/effect.js';
export function calendarEffects() {
pcEffects();
}
@@ -0,0 +1,6 @@
export * from './calendar-view-manager.js';
export * from './define.js';
export * from './layout.js';
export * from './renderer.js';
export * from './source.js';
export * from './types.js';
@@ -0,0 +1,250 @@
import type { CalendarEntry } from './types.js';
export type CalendarDayLayout = {
date: number;
inMonth: boolean;
entries: CalendarEntry[];
segments: CalendarRangeSegment[];
};
export type CalendarRangeSegment = {
entry: CalendarEntry;
weekIndex: number;
startIndex: number;
span: number;
slot: number;
startsBeforeWeek: boolean;
endsAfterWeek: boolean;
};
export type CalendarMonthLayout = {
from: number;
to: number;
weeks: CalendarDayLayout[][];
days: CalendarDayLayout[];
segments: CalendarRangeSegment[];
};
export type CalendarMonthLayoutOptions = {
month: number | Date;
entries: CalendarEntry[];
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const addDays = (date: number, days: number) => {
const current = new Date(date);
return startOfDay(
new Date(
current.getFullYear(),
current.getMonth(),
current.getDate() + days
)
);
};
const endOfDay = (date: number) => addDays(date, 1) - 1;
const toDate = (value: number | Date) =>
value instanceof Date ? value : new Date(value);
export const getCalendarVisibleMonthRange = (
month: number | Date,
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0
) => {
const cursor = toDate(month);
const monthStart = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
const startOffset = (monthStart.getDay() - weekStartsOn + 7) % 7;
const endOffset = (weekStartsOn + 6 - monthEnd.getDay() + 7) % 7;
const from = startOfDay(
new Date(
monthStart.getFullYear(),
monthStart.getMonth(),
monthStart.getDate() - startOffset
)
);
const to = endOfDay(
startOfDay(
new Date(
monthEnd.getFullYear(),
monthEnd.getMonth(),
monthEnd.getDate() + endOffset
)
)
);
return {
from,
to,
monthStart: startOfDay(monthStart),
monthEnd: endOfDay(startOfDay(monthEnd)),
};
};
const isRangeEntry = (entry: CalendarEntry) =>
entry.endAt != null &&
getRangeEndDay(entry) > startOfDay(new Date(entry.startAt));
const getRangeEndDay = (entry: CalendarEntry) => {
const endAt = entry.endAt ?? entry.startAt;
const end = new Date(endAt);
if (
entry.kind === 'external' &&
entry.allDay &&
endAt > entry.startAt &&
end.getHours() === 0 &&
end.getMinutes() === 0 &&
end.getSeconds() === 0 &&
end.getMilliseconds() === 0
) {
return addDays(startOfDay(end), -1);
}
return startOfDay(end);
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const getDayOffset = (days: CalendarDayLayout[], date: number) =>
days.findIndex(day => day.date === date);
const assignSegmentSlots = (
weeks: CalendarDayLayout[][],
segments: CalendarRangeSegment[]
) => {
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
const weekSegments = segments.filter(
segment => segment.weekIndex === weekIndex
);
const slots: boolean[][] = [];
for (const segment of weekSegments) {
let slot = 0;
while (
slots[slot]?.some(
(occupied, index) =>
occupied &&
index >= segment.startIndex &&
index < segment.startIndex + segment.span
)
) {
slot++;
}
const slotDays = (slots[slot] ??= Array.from({ length: 7 }, () => false));
for (
let index = segment.startIndex;
index < segment.startIndex + segment.span;
index++
) {
slotDays[index] = true;
}
segment.slot = slot;
}
}
};
export const getCalendarDaySegmentSlots = (
day: CalendarDayLayout,
ignoredEntryId?: string
) => {
return Math.max(
0,
...day.segments
.filter(segment => segment.entry.id !== ignoredEntryId)
.map(segment => segment.slot + 1)
);
};
export const getCalendarDayContentSlots = (
day: CalendarDayLayout,
ignoredEntryId?: string
) => {
return (
getCalendarDaySegmentSlots(day, ignoredEntryId) +
day.entries.filter(entry => entry.id !== ignoredEntryId).length
);
};
export const createCalendarMonthLayout = ({
month,
entries,
weekStartsOn = 0,
}: CalendarMonthLayoutOptions): CalendarMonthLayout => {
const range = getCalendarVisibleMonthRange(month, weekStartsOn);
const cursor = toDate(month);
const days: CalendarDayLayout[] = [];
const dayByTime = new Map<number, CalendarDayLayout>();
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
const day: CalendarDayLayout = {
date,
inMonth:
new Date(date).getMonth() === cursor.getMonth() &&
new Date(date).getFullYear() === cursor.getFullYear(),
entries: [],
segments: [],
};
days.push(day);
dayByTime.set(date, day);
}
for (const entry of entries) {
if (isRangeEntry(entry)) {
continue;
}
const day = dayByTime.get(startOfDay(new Date(entry.startAt)));
if (day) {
day.entries.push(entry);
}
}
const segments: CalendarRangeSegment[] = [];
const rangeEntries = entries.filter(isRangeEntry);
const visibleEndDay = startOfDay(new Date(range.to));
for (const entry of rangeEntries) {
const entryStart = startOfDay(new Date(entry.startAt));
const entryEnd = getRangeEndDay(entry);
if (entryEnd < range.from || entryStart > visibleEndDay) {
continue;
}
const start = clamp(entryStart, range.from, visibleEndDay);
const end = clamp(entryEnd, range.from, visibleEndDay);
const startOffset = getDayOffset(days, start);
const endOffset = getDayOffset(days, end);
if (startOffset < 0 || endOffset < 0) {
continue;
}
let offset = startOffset;
while (offset <= endOffset) {
const weekIndex = Math.floor(offset / 7);
const startIndex = offset % 7;
const weekEndOffset = weekIndex * 7 + 6;
const span = Math.min(endOffset, weekEndOffset) - offset + 1;
const segment = {
entry,
weekIndex,
startIndex,
span,
slot: 0,
startsBeforeWeek: startOffset < weekIndex * 7,
endsAfterWeek: endOffset > weekEndOffset,
};
segments.push(segment);
for (let index = 0; index < span; index++) {
days[offset + index]?.segments.push(segment);
}
offset += span;
}
}
const weeks: CalendarDayLayout[][] = [];
for (let index = 0; index < days.length; index += 7) {
weeks.push(days.slice(index, index + 7));
}
assignSegmentSlots(weeks, segments);
return { from: range.from, to: range.to, weeks, days, segments };
};
@@ -0,0 +1,87 @@
import {
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import {
CalendarPanelIcon,
DateTimeIcon,
PinIcon,
TextIcon,
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import type { DataViewRootUILogic } from '../../../core/data-view.js';
import type { CalendarSingleView } from '../calendar-view-manager.js';
import type { CalendarEntry } from '../types.js';
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
});
export const formatEntryTime = (entry: CalendarEntry) => {
const formatter = entry.allDay ? dateFormatter : dateTimeFormatter;
const start = formatter.format(new Date(entry.startAt));
if (!entry.endAt) {
return start;
}
return `${start} - ${formatter.format(new Date(entry.endAt))}`;
};
export const openCalendarEntry = (
root: DataViewRootUILogic,
view: CalendarSingleView,
entry: CalendarEntry,
target: HTMLElement,
options?: { selectEntry?: (entryId: string | undefined) => void }
) => {
if (entry.kind === 'row') {
options?.selectEntry?.(entry.id);
root.openDetailPanel({
view,
rowId: entry.rowId,
onClose: () => options?.selectEntry?.(undefined),
});
return;
}
popMenu(popupTargetFromElement(target), {
options: {
items: [
() => html`
<div class="calendar-event-popover">
<div class="calendar-event-popover-title">${entry.title}</div>
<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon"
>${CalendarPanelIcon()}</span
>
<span>${entry.calendarName ?? 'Calendar event'}</span>
</div>
<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${DateTimeIcon()}</span>
<span>${formatEntryTime(entry)}</span>
</div>
${entry.location
? html`<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${PinIcon()}</span>
<span>${entry.location}</span>
</div>`
: ''}
${entry.description
? html`<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${TextIcon()}</span>
<span class="calendar-event-popover-description"
>${entry.description}</span
>
</div>`
: ''}
</div>
`,
],
},
});
};
@@ -0,0 +1,244 @@
import type { DndController } from '@blocksuite/std';
import type { CalendarEntry, CalendarRowEntry } from '../types.js';
import { getCalendarDateFromPoint } from './hit-test.js';
export type CalendarDndEntity =
| {
type: 'calendar-entry';
entryId: string;
}
| {
type: 'doc';
docId: string;
};
type CalendarDndData = {
bsEntity?: unknown;
entity?: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const getCalendarDndEntity = (
data: unknown
): CalendarDndEntity | undefined => {
if (!isRecord(data)) {
return;
}
const bsEntity = (data as CalendarDndData).bsEntity;
if (isRecord(bsEntity)) {
if (
bsEntity.type === 'calendar-entry' &&
typeof bsEntity.entryId === 'string'
) {
return {
type: 'calendar-entry',
entryId: bsEntity.entryId,
};
}
if (bsEntity.type === 'doc' && typeof bsEntity.docId === 'string') {
return {
type: 'doc',
docId: bsEntity.docId,
};
}
}
const entity = (data as CalendarDndData).entity;
if (
isRecord(entity) &&
entity.type === 'doc' &&
typeof entity.id === 'string'
) {
return {
type: 'doc',
docId: entity.id,
};
}
return;
};
export type CalendarDndCallbacks = {
getEntry: (entryId: string) => CalendarEntry | undefined;
canDragEntry: () => boolean;
canDrop: (entity: CalendarDndEntity) => boolean;
onEntryDragStart: (entry: CalendarRowEntry) => void;
onEntryDragEnd: () => void;
onDropTargetChange: (
date: number | undefined,
entity?: CalendarDndEntity
) => void;
onDrop: (entity: CalendarDndEntity, date: number) => void;
};
type ElementCleanup = {
element: HTMLElement;
cleanup: () => void;
};
export class CalendarDnd {
private readonly entryCleanups = new Map<string, ElementCleanup>();
private rootCleanup?: ElementCleanup;
constructor(
private readonly dnd: DndController | undefined,
private readonly callbacks: CalendarDndCallbacks
) {}
bindRoot(element?: Element) {
if (!this.dnd || !(element instanceof HTMLElement)) {
this.cleanupRoot();
return;
}
if (this.rootCleanup?.element === element) {
return;
}
this.cleanupRoot();
const cleanup = this.dnd.dropTarget<CalendarDndEntity, { date?: number }>({
element,
getIsSticky: () => true,
setDropData: ({ input }) => ({
date: getCalendarDateFromPoint(element, input.clientX, input.clientY),
}),
canDrop: ({ source, input }) => {
const entity = getCalendarDndEntity(source.data);
const date = getCalendarDateFromPoint(
element,
input.clientX,
input.clientY
);
return entity && date !== undefined
? this.callbacks.canDrop(entity)
: false;
},
onDrag: ({ source, location }) => {
this.updateDropTarget(element, source.data, location.current.input);
},
onDragEnter: ({ source, location }) => {
this.updateDropTarget(element, source.data, location.current.input);
},
onDragLeave: () => {
this.callbacks.onDropTargetChange(undefined);
},
onDrop: ({ source, location }) => {
const entity = getCalendarDndEntity(source.data);
const date = getCalendarDateFromPoint(
element,
location.current.input.clientX,
location.current.input.clientY
);
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
this.callbacks.onDrop(entity, date);
}
this.callbacks.onDropTargetChange(undefined);
},
});
this.rootCleanup = { element, cleanup };
}
bindEntry(
key: string,
entry: CalendarEntry,
element?: Element,
disabled = false
) {
if (
!this.dnd ||
!(element instanceof HTMLElement) ||
entry.kind !== 'row' ||
disabled
) {
this.cleanupEntry(key);
if (element instanceof HTMLElement) {
element.setAttribute('draggable', 'false');
}
return;
}
const current = this.entryCleanups.get(key);
if (current?.element === element) {
return;
}
this.cleanupEntry(key);
const cleanup = this.dnd.draggable<CalendarDndEntity>({
element,
canDrag: () => {
const currentEntry = this.callbacks.getEntry(entry.id);
return currentEntry?.kind === 'row'
? this.callbacks.canDragEntry()
: false;
},
setDragData: () => ({
type: 'calendar-entry',
entryId: entry.id,
}),
setDragPreview: ({ container, setOffset }) => {
const currentEntry = this.callbacks.getEntry(entry.id);
const preview = document.createElement('div');
preview.textContent = currentEntry?.title || 'Untitled';
preview.style.cssText =
'padding:0 6px;height:22px;line-height:22px;border-radius:4px;' +
'font-size:12px;white-space:nowrap;overflow:hidden;' +
'background:var(--affine-hover-color,#f5f5f5);' +
'color:var(--affine-text-primary-color,#333);' +
'max-width:140px;text-overflow:ellipsis;pointer-events:none;';
container.append(preview);
setOffset({ x: 10, y: 11 });
},
onDragStart: () => {
const currentEntry = this.callbacks.getEntry(entry.id);
if (currentEntry?.kind === 'row') {
this.callbacks.onEntryDragStart(currentEntry);
}
},
onDrop: () => {
this.callbacks.onEntryDragEnd();
},
});
this.entryCleanups.set(key, { element, cleanup });
}
cleanup() {
this.cleanupRoot();
for (const key of this.entryCleanups.keys()) {
this.cleanupEntry(key);
}
}
private cleanupEntry(key: string) {
this.entryCleanups.get(key)?.cleanup();
this.entryCleanups.delete(key);
}
private cleanupRoot() {
this.rootCleanup?.cleanup();
this.rootCleanup = undefined;
}
private updateDropTarget(
root: HTMLElement,
data: unknown,
input: {
clientX: number;
clientY: number;
}
) {
const entity = getCalendarDndEntity(data);
const date = getCalendarDateFromPoint(root, input.clientX, input.clientY);
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
this.callbacks.onDropTargetChange(date, entity);
} else {
this.callbacks.onDropTargetChange(undefined);
}
}
}
@@ -0,0 +1,8 @@
import { CalendarViewUI } from './view.js';
export function pcEffects() {
if (customElements.get('affine-data-view-calendar')) {
return;
}
customElements.define('affine-data-view-calendar', CalendarViewUI);
}
@@ -0,0 +1,38 @@
export const getCalendarDateFromPoint = (
root: HTMLElement,
clientX: number,
clientY: number
) => {
const doc = root.ownerDocument;
const hitStack = doc.elementsFromPoint(clientX, clientY);
for (const element of hitStack) {
const day = element.closest<HTMLElement>('.calendar-day[data-date]');
if (day && root.contains(day)) {
return Number(day.dataset['date']);
}
}
for (const element of hitStack) {
const week =
element.closest<HTMLElement>('.calendar-week') ??
element.closest<HTMLElement>('.calendar-segments')?.parentElement;
if (week && root.contains(week)) {
const days = week.querySelectorAll<HTMLElement>('.calendar-day');
for (const day of days) {
const rect = day.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX < rect.right &&
clientY >= rect.top &&
clientY < rect.bottom &&
day.dataset['date']
) {
return Number(day.dataset['date']);
}
}
}
}
return;
};
@@ -0,0 +1,708 @@
import { css } from 'lit';
export const calendarViewStyles = css`
affine-data-view-calendar {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
--calendar-entry-height: 22px;
--calendar-entry-gap: 3px;
--calendar-entry-slot-height: calc(
var(--calendar-entry-height) + var(--calendar-entry-gap)
);
--calendar-grid-border-color: color-mix(
in srgb,
var(--affine-border-color) 58%,
transparent
);
--calendar-entry-bg: color-mix(
in srgb,
var(--affine-primary-color) 12%,
var(--affine-background-primary-color)
);
--calendar-entry-hover-bg: color-mix(
in srgb,
var(--affine-primary-color) 18%,
var(--affine-background-primary-color)
);
--calendar-entry-text-color: color-mix(
in srgb,
var(--affine-primary-color) 72%,
var(--affine-text-primary-color)
);
--calendar-external-fallback-color: #b45309;
}
.calendar-scroll {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.calendar-shell {
position: relative;
min-width: 720px;
padding: 0 0 12px;
}
.calendar-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
margin-bottom: 8px;
}
.calendar-title {
color: var(--affine-text-primary-color);
font-size: 15px;
font-weight: 600;
}
.calendar-nav {
display: flex;
gap: 6px;
}
.calendar-nav button,
.calendar-setup button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--affine-border-color);
border-radius: 6px;
background: var(--affine-background-primary-color);
color: var(--affine-text-primary-color);
height: 28px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.calendar-nav button svg,
.calendar-setup button svg,
.calendar-new-row svg,
.calendar-empty-month-hint-action svg,
.calendar-empty-month-hint-close svg {
width: 16px;
height: 16px;
color: var(--affine-icon-secondary);
flex: 0 0 auto;
}
.calendar-nav .calendar-icon-button {
width: 28px;
padding: 5px;
}
.calendar-nav .calendar-today-button {
color: var(--affine-primary-color);
}
.calendar-weekdays,
.calendar-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.calendar-week {
position: relative;
}
.calendar-segments {
position: absolute;
left: 0;
right: 0;
top: 30px;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: var(--calendar-entry-slot-height);
row-gap: 0;
column-gap: 0;
padding: 0;
pointer-events: none;
}
.calendar-segments .calendar-entry {
align-self: start;
height: var(--calendar-entry-height);
box-sizing: border-box;
pointer-events: auto;
margin: 0 6px;
}
.calendar-segments .calendar-entry-preview {
align-self: start;
pointer-events: none;
margin: 0 6px;
}
.calendar-weekday {
color: var(--affine-text-secondary-color);
font-size: 12px;
padding: 4px 6px;
user-select: none;
-webkit-user-select: none;
}
.calendar-grid {
border-top: 1px solid var(--calendar-grid-border-color);
border-left: 1px solid var(--calendar-grid-border-color);
}
.calendar-day {
position: relative;
min-height: 112px;
border-right: 1px solid var(--calendar-grid-border-color);
border-bottom: 1px solid var(--calendar-grid-border-color);
padding: 6px;
}
.calendar-day.is-outside {
background: color-mix(
in srgb,
var(--affine-background-secondary-color) 55%,
var(--affine-background-primary-color)
);
}
.calendar-day:not(.is-outside):hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 2%,
var(--affine-background-primary-color)
);
}
.calendar-day.is-drop-target {
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
background: color-mix(in srgb, var(--affine-primary-color) 8%, transparent);
}
.calendar-day.is-today {
background: color-mix(
in srgb,
var(--affine-primary-color) 6%,
var(--affine-background-primary-color)
);
}
.calendar-day-number {
display: flex;
align-items: center;
justify-content: center;
width: max-content;
min-width: 20px;
height: 20px;
padding: 0 2px;
border-radius: 4px;
color: var(--affine-text-secondary-color);
font-size: 12px;
line-height: 18px;
margin-bottom: 4px;
user-select: none;
-webkit-user-select: none;
}
.calendar-day:not(.is-outside) .calendar-day-number {
color: var(--affine-text-primary-color);
}
.calendar-day.is-outside .calendar-day-number {
color: color-mix(
in srgb,
var(--affine-text-secondary-color) 60%,
transparent
);
}
.calendar-day.is-today .calendar-day-number {
color: var(--affine-primary-color);
font-weight: 600;
}
.calendar-day.is-today:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 9%,
var(--affine-background-primary-color)
);
}
.calendar-entry {
position: relative;
display: flex;
align-items: center;
gap: 4px;
min-height: var(--calendar-entry-height);
margin-top: var(--calendar-entry-gap);
padding: 0 6px;
border-radius: 4px;
color: var(--calendar-entry-text-color);
background: var(--calendar-entry-bg);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.calendar-nav button:hover,
.calendar-setup button:hover {
background: var(--affine-hover-color);
}
.calendar-entry.row:hover {
background: var(--calendar-entry-hover-bg);
}
.calendar-entry:focus-visible {
outline: 1px solid var(--affine-primary-color);
outline-offset: 1px;
}
.calendar-entry.external:hover {
opacity: 0.9;
}
.calendar-entry.selected {
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
background: color-mix(
in srgb,
var(--affine-primary-color) 15%,
var(--calendar-entry-bg)
);
}
.calendar-entry.continues-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.calendar-entry.continues-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.calendar-entry-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry-title.is-empty {
color: var(--affine-text-secondary-color);
}
.calendar-entry-title.title-segments {
display: inline-flex;
align-items: center;
gap: 2px;
}
.calendar-entry-title-segment {
display: inline-flex;
align-items: center;
min-width: 0;
}
.calendar-entry-title-segment.linked-doc-segment {
gap: 3px;
min-width: 14px;
}
.calendar-entry-title-segment.linked-doc-segment svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.calendar-entry-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry-title-segment.linked-doc-segment .calendar-entry-title-text {
flex-shrink: 1;
}
.calendar-entry-properties {
display: inline-flex;
gap: 3px;
min-width: 0;
}
.calendar-entry-property {
max-width: 72px;
padding: 1px 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--affine-pure-white) 80%, transparent);
color: var(--affine-text-primary-color);
font-size: 10px;
font-weight: 500;
line-height: 14px;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry.external {
color: var(--affine-pure-white);
background: var(
--calendar-external-color,
var(--calendar-external-fallback-color)
);
}
.calendar-entry[draggable='true'] {
cursor: grab;
}
.calendar-entry[draggable='true']:active {
opacity: 0.7;
}
.calendar-resize-handle {
display: none;
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 1;
}
.calendar-resize-handle.left {
left: 0;
border-radius: 4px 0 0 4px;
}
.calendar-resize-handle.right {
right: 0;
border-radius: 0 4px 4px 0;
}
.calendar-resize-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 10px;
transform: translate(-50%, -50%);
border-radius: 1px;
background: var(--affine-icon-secondary);
}
.calendar-resize-handle:hover::after {
background: var(--affine-primary-color);
}
.calendar-entry:hover .calendar-resize-handle {
display: block;
}
.calendar-entry-preview {
display: flex;
align-items: center;
gap: 4px;
min-height: var(--calendar-entry-height);
height: var(--calendar-entry-height);
margin-top: var(--calendar-entry-gap);
padding: 0 6px;
box-sizing: border-box;
border-radius: 4px;
border: 1.5px dashed var(--affine-primary-color);
background: color-mix(in srgb, var(--affine-primary-color) 6%, transparent);
color: var(--affine-primary-color);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.calendar-entry-preview svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.calendar-entry-preview.continues-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
padding-left: 6px;
}
.calendar-entry-preview.continues-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
padding-right: 6px;
}
.calendar-day-entries > .calendar-entry:first-child,
.calendar-day-entries > .calendar-entry-preview:first-child {
margin-top: 0;
}
.calendar-day-entries {
padding-top: calc(
var(--calendar-segment-slots, 0) * var(--calendar-entry-slot-height)
);
}
.calendar-new-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 24px;
margin-top: 3px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--affine-primary-color);
font-size: 12px;
font-weight: 500;
line-height: 18px;
padding: 3px 8px;
opacity: 0;
cursor: pointer;
box-sizing: border-box;
transition:
opacity 0.1s ease,
background 0.1s ease;
}
.calendar-new-row svg,
.calendar-empty-month-hint-action svg {
width: 14px;
height: 14px;
color: var(--affine-primary-color);
}
.calendar-day:hover .calendar-new-row,
.calendar-new-row:focus-visible {
opacity: 1;
}
.calendar-day:hover .calendar-new-row {
background: color-mix(
in srgb,
var(--affine-primary-color) 10%,
var(--affine-background-primary-color)
);
}
.calendar-day:hover .calendar-new-row:disabled,
.calendar-day.is-today:hover .calendar-new-row:disabled,
.calendar-new-row:disabled {
background: transparent;
opacity: 0;
pointer-events: none;
}
.calendar-day.is-today:hover .calendar-new-row,
.calendar-day.is-today .calendar-new-row:focus-visible {
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.calendar-day.is-today .calendar-new-row:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 88%,
var(--affine-pure-white)
);
}
.calendar-day.is-today:hover .calendar-new-row svg,
.calendar-day.is-today .calendar-new-row:focus-visible svg {
color: var(--affine-pure-white);
}
.calendar-new-row:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 16%,
var(--affine-background-primary-color)
);
}
.calendar-empty-month-hint {
position: absolute;
top: 44px;
left: 8px;
right: 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 6px 8px 6px 12px;
border: 1px solid
color-mix(in srgb, var(--affine-primary-color) 18%, transparent);
border-radius: 6px;
background: color-mix(
in srgb,
var(--affine-background-primary-color) 92%,
var(--affine-primary-color)
);
box-shadow: var(--affine-menu-shadow);
box-sizing: border-box;
}
.calendar-empty-month-hint-copy {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.calendar-empty-month-hint-title {
flex: 0 0 auto;
color: var(--affine-text-primary-color);
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.calendar-empty-month-hint-body {
min-width: 0;
color: var(--affine-text-secondary-color);
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-empty-month-hint-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.calendar-empty-month-hint-action,
.calendar-empty-month-hint-close {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 24px;
padding: 3px 8px;
border: 0;
border-radius: 5px;
background: color-mix(
in srgb,
var(--affine-primary-color) 10%,
var(--affine-background-primary-color)
);
color: var(--affine-primary-color);
font-size: 12px;
font-weight: 500;
line-height: 18px;
cursor: pointer;
}
.calendar-empty-month-hint-close {
width: 24px;
padding: 4px;
background: transparent;
color: var(--affine-icon-secondary);
}
.calendar-empty-month-hint-close svg {
width: 14px;
height: 14px;
}
.calendar-empty-month-hint-action:hover,
.calendar-empty-month-hint-close:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 16%,
var(--affine-background-primary-color)
);
}
.calendar-setup-wrap {
position: relative;
}
.calendar-setup-wrap .calendar-shell {
filter: grayscale(1) blur(1px);
opacity: 0.55;
pointer-events: none;
}
.calendar-setup {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.calendar-setup button {
height: 32px;
padding: 7px 12px;
}
.calendar-event-popover {
display: flex;
flex-direction: column;
gap: 4px;
width: 318px;
padding: 4px;
font-size: 13px;
line-height: 20px;
}
.calendar-event-popover-title {
padding: 2px 4px;
color: var(--affine-text-primary-color);
font-weight: 600;
font-size: 14px;
line-height: 22px;
margin-bottom: 2px;
}
.calendar-event-popover-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 2px 4px;
color: var(--affine-text-secondary-color);
}
.calendar-event-popover-icon {
display: flex;
align-items: center;
flex: 0 0 16px;
height: 20px;
color: var(--affine-icon-secondary);
}
.calendar-event-popover-icon svg {
width: 16px;
height: 16px;
}
.calendar-event-popover-description {
white-space: pre-wrap;
word-break: break-word;
}
`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
import './pc/effect.js';
import { createIcon } from '../../core/utils/uni-icon.js';
import type { DataViewUILogicBaseConstructor } from '../../core/view/data-view-base.js';
import { calendarViewModel } from './define.js';
import { CalendarViewUILogic } from './pc/view.js';
export const calendarViewMeta = calendarViewModel.createMeta({
icon: createIcon('TodayIcon'),
pcLogic: () =>
CalendarViewUILogic as unknown as DataViewUILogicBaseConstructor,
});
@@ -0,0 +1,23 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { DataSource } from '../../core/data-source/base.js';
import type {
CalendarExternalSource,
CalendarStoredViewData,
} from './types.js';
export type CalendarExternalSourceFactory = {
id: string;
create(viewData: CalendarStoredViewData): CalendarExternalSource;
};
export const CalendarExternalSourceProvider =
createIdentifier<CalendarExternalSourceFactory>('calendar-external-source');
export const getCalendarExternalSources = (
dataSource: DataSource,
viewData: CalendarStoredViewData
) =>
Array.from(
dataSource.provider.getAll(CalendarExternalSourceProvider).values()
).map(source => source.create(viewData));
@@ -0,0 +1,97 @@
import type { FilterGroup } from '../../core/filter/types.js';
import type { Sort } from '../../core/sort/types.js';
import type { BasicViewDataType } from '../../core/view/data-view.js';
export type CalendarWorkspaceSourceConfig = {
enabled: boolean;
subscriptionIds?: string[];
};
export type CalendarUiData = {
emptyMonthHintDismissed?: boolean;
};
export type CalendarCardProperty = {
propertyId: string;
value: string;
};
export type CalendarTitleSegment = {
text: string;
linkedDoc?: boolean;
};
type CalendarViewDataShape = {
filter: FilterGroup;
sort?: Sort;
date: {
startColumnId?: string;
endColumnId?: string;
};
card: {
titleColumnId?: string;
visiblePropertyIds: string[];
};
sources: {
workspaceCalendar?: CalendarWorkspaceSourceConfig;
};
ui?: CalendarUiData;
};
export type CalendarViewData = BasicViewDataType<
'calendar',
CalendarViewDataShape
>;
export type CalendarStoredViewData = CalendarViewData;
export type CalendarEntryBase = {
id: string;
sourceId: string;
title: string;
color?: string;
startAt: number;
endAt?: number;
allDay?: boolean;
};
export type CalendarRowEntry = CalendarEntryBase & {
kind: 'row';
sourceId: 'database';
rowId: string;
titleSegments?: CalendarTitleSegment[];
cardProperties: CalendarCardProperty[];
canResizeRange: boolean;
};
export type CalendarExternalEntry = CalendarEntryBase & {
kind: 'external';
sourceId: string;
externalId: string;
calendarName?: string;
location?: string;
description?: string;
canResizeRange: false;
};
export type CalendarEntry = CalendarRowEntry | CalendarExternalEntry;
export type CalendarEntryRange = {
from: number;
to: number;
};
export type CalendarExternalSource = {
id: string;
getSubscriptionOptions?(): CalendarExternalSourceSubscription[];
openConnectSettings?(): void;
getEntries(
range: CalendarEntryRange
): CalendarExternalEntry[] | Promise<CalendarExternalEntry[]>;
};
export type CalendarExternalSourceSubscription = {
id: string;
name: string;
color?: string;
};
@@ -1,13 +1,45 @@
import { createViewConvert } from '../core/view/convert.js';
import { calendarViewModel } from './calendar/index.js';
import { kanbanViewModel } from './kanban/index.js';
import { tableViewModel } from './table/index.js';
const headerToCalendarCard = (header?: { titleColumn?: string }) => ({
titleColumnId: header?.titleColumn,
visiblePropertyIds: [],
});
const calendarCardToHeader = (card?: { titleColumnId?: string }) => ({
titleColumn: card?.titleColumnId,
});
export const viewConverts = [
createViewConvert(tableViewModel, kanbanViewModel, data => ({
filter: data.filter,
header: data.header,
})),
createViewConvert(kanbanViewModel, tableViewModel, data => ({
filter: data.filter,
header: data.header,
groupBy: data.groupBy,
})),
createViewConvert(tableViewModel, calendarViewModel, data => ({
filter: data.filter,
sort: data.sort,
card: headerToCalendarCard(data.header),
})),
createViewConvert(kanbanViewModel, calendarViewModel, data => ({
filter: data.filter,
sort: data.sort,
card: headerToCalendarCard(data.header),
})),
createViewConvert(calendarViewModel, tableViewModel, data => ({
filter: data.filter,
sort: data.sort,
header: calendarCardToHeader(data.card),
})),
createViewConvert(calendarViewModel, kanbanViewModel, data => ({
filter: data.filter,
sort: data.sort,
header: calendarCardToHeader(data.card),
})),
];
@@ -1,7 +1,9 @@
import { calendarEffects } from './calendar/effect.js';
import { kanbanEffects } from './kanban/effect.js';
import { tableEffects } from './table/effect.js';
export function viewPresetsEffects() {
calendarEffects();
kanbanEffects();
tableEffects();
}
@@ -1,6 +1,8 @@
import { calendarViewMeta } from './calendar/index.js';
import { kanbanViewMeta } from './kanban/index.js';
import { tableViewMeta } from './table/index.js';
export * from './calendar/index.js';
export * from './convert.js';
export * from './kanban/index.js';
export * from './table/index.js';
@@ -8,4 +10,5 @@ export * from './table/index.js';
export const viewPresets = {
tableViewMeta: tableViewMeta,
kanbanViewMeta: kanbanViewMeta,
calendarViewMeta: calendarViewMeta,
};
@@ -1,4 +1,5 @@
import {
type Menu,
menu,
type MenuButtonData,
type MenuConfig,
@@ -16,22 +17,22 @@ import {
InfoIcon,
LayoutIcon,
MoreHorizontalIcon,
PlusIcon,
SortIcon,
} from '@blocksuite/icons/lit';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { popPropertiesSetting } from '../../../../core/common/properties.js';
import { filterTraitKey } from '../../../../core/filter/trait.js';
import {
popGroupSetting,
popSelectGroupByProperty,
buildGroupSelectItems,
buildGroupSettingItems,
} from '../../../../core/group-by/setting.js';
import { groupTraitKey } from '../../../../core/group-by/trait.js';
import {
type DataViewUILogicBase,
emptyFilterGroup,
popCreateFilter,
renderUniLit,
} from '../../../../core/index.js';
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
import { sortTraitKey } from '../../../../core/sort/manager.js';
import { createSortUtils } from '../../../../core/sort/utils.js';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
const styles = css`
.affine-database-toolbar-item.more-action {
@@ -95,379 +94,486 @@ declare global {
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
}
}
const createSettingMenus = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
reopen: () => void,
closeMenu: () => void
) => {
const view = dataViewLogic.view;
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}`,
select: () => {
popPropertiesSetting(
target,
{
view: view,
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
},
})
);
const filterTrait = view.traitGet(filterTraitKey);
if (filterTrait) {
const filterCount = filterTrait.filter$.value.conditions.length;
settingItems.push(
menu.action({
name: 'Filter',
prefix: FilterIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${filterCount === 0
? ''
: filterCount === 1
? '1 filter'
: `${filterCount} filters`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
if (!filterTrait.filter$.value.conditions.length) {
popCreateFilter(
target,
{
vars: view.vars$,
onBack: reopen,
onClose: closeMenu,
onSelect: filter => {
filterTrait.filterSet({
...(filterTrait.filter$.value ?? emptyFilterGroup),
conditions: [
...filterTrait.filter$.value.conditions,
filter,
],
});
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
},
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
);
}
const sortTrait = view.traitGet(sortTraitKey);
if (sortTrait) {
const sortCount = sortTrait.sortList$.value.length;
settingItems.push(
menu.action({
name: 'Sort',
prefix: SortIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${sortCount === 0
? ''
: sortCount === 1
? '1 sort'
: `${sortCount} sorts`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const sortList = sortTrait.sortList$.value;
const sortUtils = createSortUtils(
sortTrait,
dataViewLogic.eventTrace
);
if (!sortList.length) {
popCreateSort(
target,
{
sortUtils: sortUtils,
onBack: reopen,
onClose: closeMenu,
},
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popSortRoot(
target,
{
sortUtils: sortUtils,
title: {
text: 'Sort',
onBack: reopen,
onClose: closeMenu,
},
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
);
}
const groupTrait = view.traitGet(groupTraitKey);
if (groupTrait) {
settingItems.push(
menu.action({
name: 'Group',
prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const groupBy = groupTrait.property$.value;
if (!groupBy) {
popSelectGroupByProperty(
target,
groupTrait,
{
onSelect: () =>
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]),
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
} else {
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]);
}
},
})
);
}
return settingItems;
type Page =
| 'main'
| 'properties'
| 'filter'
| 'sort'
| 'group'
| 'group-select'
| 'custom';
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
main: 'View settings',
properties: 'Properties',
filter: 'Filter',
sort: 'Sort',
group: 'Group',
'group-select': 'Group by',
};
export const popViewOptions = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
onClose?: () => void
) => {
const view = dataViewLogic.view;
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">${meta.model.defaultName}</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menu}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
items.push(
menu.group({
items: createSettingMenus(target, dataViewLogic, reopen, () =>
handler.close()
),
})
);
items.push(
const currentPage = signal<Page>('main');
const pageStack: Page[] = ['main'];
let menuHandler!: ReturnType<typeof popMenu>;
let mainPageHeight: number | null = null;
let customPageTitle = '';
let customPageItems: () => MenuConfig[] = () => [];
const isDesktopMenu = () =>
menuHandler.menu.menuElement.tagName.toLowerCase() === 'affine-menu';
const navigate = (page: Page) => {
if (!isDesktopMenu()) {
pageStack.push(page);
currentPage.value = page;
return;
}
if (mainPageHeight === null) {
mainPageHeight =
menuHandler.menu.menuElement.getBoundingClientRect().height;
}
menuHandler.menu.menuElement.style.height = `${mainPageHeight}px`;
pageStack.push(page);
currentPage.value = page;
};
const goBack = () => {
if (pageStack.length > 1) {
pageStack.pop();
const dest = pageStack[pageStack.length - 1] ?? 'main';
currentPage.value = dest;
if (dest === 'main') {
menuHandler.menu.menuElement.style.height = '';
}
}
};
const navigateToCustomPage = (
title: string,
getItems: () => MenuConfig[]
) => {
customPageTitle = title;
customPageItems = getItems;
navigate('custom');
};
const titleConfig = {
get text() {
if (currentPage.value === 'custom') return customPageTitle;
return (
pageTitles[currentPage.value as Exclude<Page, 'custom'>] ??
'View settings'
);
},
get onBack(): ((menu: Menu) => false) | undefined {
return currentPage.value !== 'main'
? (_: Menu) => {
goBack();
return false;
}
: undefined;
},
get postfix() {
if (currentPage.value !== 'properties') return undefined;
const items = view.propertiesRaw$.value;
const isAllShowed = items.every(p => !p.hide$.value);
const clickChangeAll = () => {
items.forEach(p => {
if (p.hideCanSet) p.hideSet(isAllShowed);
});
};
return () =>
html`<div
class="properties-group-op"
style="padding:4px 8px;font-size:12px;line-height:20px;font-weight:500;border-radius:4px;cursor:pointer;color:var(--affine-primary-color);"
@click="${clickChangeAll}"
>
${isAllShowed ? 'Hide All' : 'Show All'}
</div>`;
},
get onClose() {
return () => menuHandler?.menu.close();
},
};
const getPropertiesPageItems = (): MenuConfig[] => [
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
() =>
html`<data-view-properties-setting
.view="${view}"
></data-view-properties-setting>`,
],
})
);
handler = popMenu(target, {
}),
];
const getFilterPageItems = (): MenuConfig[] => {
const filterTrait = view.traitGet(filterTraitKey);
if (!filterTrait) return getMainPageItems();
return [
menu.group({
items: [
() =>
html`<filter-root-view
.onBack="${goBack}"
.vars="${view.vars$}"
.filterGroup="${filterTrait.filter$}"
.onChange="${filterTrait.filterSet}"
></filter-root-view>`,
],
}),
menu.group({
items: [
menu.action({
name: 'Add',
prefix: PlusIcon(),
select: ele => {
const value = filterTrait.filter$.value;
popCreateFilter(popupTargetFromElement(ele), {
vars: view.vars$,
onSelect: filter => {
filterTrait.filterSet({
...value,
conditions: [...value.conditions, filter],
});
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
});
return false;
},
}),
],
}),
];
};
const getSortPageItems = (): MenuConfig[] => {
const sortTrait = view.traitGet(sortTraitKey);
if (!sortTrait) return getMainPageItems();
const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace);
return [
() => html`<sort-root-view .sortUtils="${sortUtils}"></sort-root-view>`,
menu.action({
name: 'Add sort',
prefix: PlusIcon(),
select: ele => {
popCreateSort(popupTargetFromElement(ele), { sortUtils });
return false;
},
}),
menu.action({
name: 'Delete',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
sortUtils.removeAll();
},
}),
];
};
const getGroupPageItems = (): MenuConfig[] => {
const groupTrait = view.traitGet(groupTraitKey);
if (!groupTrait) return getMainPageItems();
const gProp = groupTrait.property$.value;
if (!gProp) return [];
return buildGroupSettingItems(
groupTrait,
() => navigate('group-select'),
() => navigate('main')
);
};
const getGroupSelectPageItems = (): MenuConfig[] => {
const groupTrait = view.traitGet(groupTraitKey);
if (!groupTrait) return getMainPageItems();
return buildGroupSelectItems(groupTrait, id => {
if (id) {
if (pageStack.at(-1) === 'group-select') {
pageStack[pageStack.length - 1] = 'group';
} else {
pageStack.push('group');
}
currentPage.value = 'group';
} else {
while (pageStack.length > 1) pageStack.pop();
currentPage.value = 'main';
}
});
};
const getMainPageItems = (): MenuConfig[] => {
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
disableAutoFocus: true,
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menuObj => {
const viewTypeItems = menuObj.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menuObj => {
if (!menuObj.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
style="width:100%;min-width:0;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 4px;white-space: nowrap;box-sizing:border-box;"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">
${meta.model.defaultName}
</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menuObj}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
>
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:4px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('properties');
return false;
},
})
);
const filterTrait = view.traitGet(filterTraitKey);
if (filterTrait) {
const filterCount = filterTrait.filter$.value.conditions.length;
settingItems.push(
menu.action({
name: 'Filter',
prefix: FilterIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${filterCount === 0
? ''
: filterCount === 1
? '1 active'
: `${filterCount} active`}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('filter');
return false;
},
})
);
}
const sortTrait = view.traitGet(sortTraitKey);
if (sortTrait) {
const sortCount = sortTrait.sortList$.value.length;
settingItems.push(
menu.action({
name: 'Sort',
prefix: SortIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${sortCount === 0
? ''
: sortCount === 1
? '1 active'
: `${sortCount} active`}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('sort');
return false;
},
})
);
}
const groupTrait = view.traitGet(groupTraitKey);
if (groupTrait) {
settingItems.push(
menu.action({
name: 'Group',
prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
const hasGroup = !!groupTrait.property$.value;
navigate(hasGroup ? 'group' : 'group-select');
return false;
},
})
);
}
items.push(menu.group({ items: settingItems }));
const viewSpecificItems =
(
dataViewLogic as DataViewUILogicBase & {
getViewOptionsSettingItems?: (
navigateToSubPage?: (
title: string,
getItems: () => MenuConfig[]
) => void,
goBack?: () => void
) => MenuConfig[];
}
).getViewOptionsSettingItems?.(navigateToCustomPage, goBack) ?? [];
if (viewSpecificItems.length) {
items.push(menu.group({ items: viewSpecificItems }));
}
items.push(
menu.group({
items: [
menu.action({
name: 'Duplicate view',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete view',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
],
})
);
return items;
};
const getPageItems = (): MenuConfig[] => {
switch (currentPage.value) {
case 'properties':
return getPropertiesPageItems();
case 'filter':
return getFilterPageItems();
case 'sort':
return getSortPageItems();
case 'group':
return getGroupPageItems();
case 'group-select':
return getGroupSelectPageItems();
case 'custom':
return customPageItems();
default:
return getMainPageItems();
}
};
menuHandler = popMenu(target, {
options: {
title: {
text: 'View settings',
onClose: () => handler.close(),
},
items,
onClose: onClose,
title: titleConfig,
items: [menu.dynamic(getPageItems)],
onClose,
},
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
@@ -475,6 +581,23 @@ export const popViewOptions = (
shift({ crossAxis: true }),
],
});
handler.menu.menuElement.style.minHeight = '550px';
return handler;
if (isDesktopMenu()) {
menuHandler.menu.menuElement.style.minWidth = '380px';
menuHandler.menu.menuElement.style.maxWidth = '380px';
menuHandler.menu.menuElement.style.borderRadius = '10px';
menuHandler.menu.menuElement.style.padding = '12px';
menuHandler.menu.menuElement.style.gap = '10px';
requestAnimationFrame(() => {
const bodyEl =
menuHandler.menu.menuElement.querySelector<HTMLElement>(
'.affine-menu-body'
);
if (bodyEl) {
bodyEl.style.overflowY = 'auto';
bodyEl.style.flex = '1';
bodyEl.style.minHeight = '0';
}
});
}
return menuHandler;
};
+50 -31
View File
@@ -2,14 +2,48 @@ import {
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import type { Command, TextSelection } from '@blocksuite/std';
import type { InlineRange } from '@blocksuite/std/inline';
function openInlineLatexEditor(
inlineEditor: AffineInlineEditor,
index: number
) {
inlineEditor
.waitForUpdate()
.then(async () => {
await inlineEditor.waitForUpdate();
const textPoint = inlineEditor.getTextPoint(index);
if (!textPoint) return;
const [text] = textPoint;
const latexNode = text.parentElement?.closest('affine-latex-node');
if (!latexNode) return;
latexNode.toggleEditor();
})
.catch(console.error);
}
function getSingleBlockInlineRange(
textSelection: TextSelection
): InlineRange | null {
if (textSelection.to) {
return null;
}
return {
index: textSelection.from.index,
length: textSelection.from.length,
};
}
export const insertInlineLatex: Command<{
currentTextSelection?: TextSelection;
textSelection?: TextSelection;
}> = (ctx, next) => {
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
if (!textSelection || !textSelection.isCollapsed()) return;
if (!textSelection) return;
const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId);
if (!blockComponent) return;
@@ -20,24 +54,19 @@ export const insertInlineLatex: Command<{
const inlineEditor = richText.inlineEditor;
if (!inlineEditor) return;
inlineEditor.insertText(
{
index: textSelection.from.index,
length: 0,
},
' '
);
inlineEditor.formatText(
{
index: textSelection.from.index,
length: 1,
},
{
latex: '',
}
);
const inlineRange = getSingleBlockInlineRange(textSelection);
if (!inlineRange) return;
const latex = textSelection.isCollapsed()
? ''
: inlineEditor.yTextString.slice(
inlineRange.index,
inlineRange.index + inlineRange.length
);
inlineEditor.insertText(inlineRange, ' ', { latex });
inlineEditor.setInlineRange({
index: textSelection.from.index,
index: inlineRange.index,
length: 1,
});
@@ -56,19 +85,9 @@ export const insertInlineLatex: Command<{
control: 'create inline equation',
});
inlineEditor
.waitForUpdate()
.then(async () => {
await inlineEditor.waitForUpdate();
const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1);
if (!textPoint) return;
const [text] = textPoint;
const latexNode = text.parentElement?.closest('affine-latex-node');
if (!latexNode) return;
latexNode.toggleEditor();
})
.catch(console.error);
if (textSelection.isCollapsed()) {
openInlineLatexEditor(inlineEditor, inlineRange.index + 1);
}
next();
};
@@ -15,7 +15,7 @@ import {
import type { DeltaInsert } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import katex from 'katex';
import { css, html, render } from 'lit';
import { css, html, type PropertyValues, render } from 'lit';
import { property } from 'lit/decorators.js';
export class AffineLatexNode extends SignalWatcher(
@@ -85,6 +85,8 @@ export class AffineLatexNode extends SignalWatcher(
private _editorAbortController: AbortController | null = null;
private _isEditorOpen = false;
readonly latex$ = signal('');
readonly latexEditorSignal = signal('');
@@ -174,6 +176,22 @@ export class AffineLatexNode extends SignalWatcher(
return result;
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (!changedProperties.has('delta') || this._isEditorOpen) {
return;
}
const latex = this.deltaLatex;
if (this.latex$.peek() !== latex) {
this.latex$.value = latex;
}
if (this.latexEditorSignal.peek() !== latex) {
this.latexEditorSignal.value = latex;
}
}
override render() {
return html`<span class="affine-latex" data-selected=${this.selected}
><div class="latex-container"></div>
@@ -212,9 +230,11 @@ export class AffineLatexNode extends SignalWatcher(
},
});
this._isEditorOpen = true;
this._editorAbortController.signal.addEventListener(
'abort',
() => {
this._isEditorOpen = false;
portal.remove();
const latex = this.latexEditorSignal.peek();
this.latex$.value = latex;
@@ -13,6 +13,7 @@ import {
QuoteIcon,
TextIcon,
} from '@blocksuite/affine-components/icons';
import { TeXIcon } from '@blocksuite/icons/lit';
import type { TemplateResult } from 'lit';
/**
@@ -119,6 +120,15 @@ export const textConversionConfigs: TextConversionConfig[] = [
hotkey: [`Mod-Alt-c`],
icon: CodeBlockIcon,
},
{
flavour: 'affine:latex',
type: undefined,
name: 'Equation',
description: 'Formula block with LaTeX rendering.',
hotkey: null,
icon: TeXIcon(),
searchAlias: ['mathBlock', 'equationBlock', 'latexBlock'],
},
{
flavour: 'affine:paragraph',
type: 'quote',
@@ -222,6 +222,17 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
});
},
},
{
name: 'Equation',
showWhen: ({ std }) =>
std.store.schema.flavourSchemaMap.has('affine:latex'),
icon: TeXIcon(),
action: ({ std }) => {
std.command.exec(updateBlockType, {
flavour: 'affine:latex',
});
},
},
{
name: 'Quote',
showWhen: ({ std }) =>
@@ -260,6 +260,17 @@ function convertGfmCallouts(markdown: string): string {
return lines.join('\n');
}
function stripBearMetadataComments(markdown: string): string {
let current = markdown;
while (true) {
const next = current.replace(/<!--\s*\{[^}]*\}\s*-->/g, '');
if (next === current) {
return current;
}
current = next;
}
}
const HIGHLIGHT_COLOR_MAP: Record<string, string> = {
'\uD83D\uDFE2': 'green',
'\uD83D\uDD35': 'blue',
@@ -426,9 +437,7 @@ async function importBearBackup({
entry.bundlePath.split('/').findLast(Boolean) ?? 'Untitled';
const title = extractTitle(cleanedMarkdown, bundleDirName);
const markdown = convertHighlights(
convertGfmCallouts(
cleanedMarkdown.replace(/<!--\s*\{[^}]*\}\s*-->/g, '')
)
convertGfmCallouts(stripBearMetadataComments(cleanedMarkdown))
);
// Read assets on demand (decompress only this bundle's assets)
+4 -4
View File
@@ -15,7 +15,7 @@
"tests/*"
],
"engines": {
"node": "<23.0.0"
"node": ">=22.12.0 <23.0.0"
},
"scripts": {
"affine": "r affine.ts",
@@ -51,7 +51,7 @@
},
"devDependencies": {
"@affine-tools/cli": "workspace:*",
"@capacitor/cli": "^7.0.0",
"@capacitor/cli": "^7.6.5",
"@eslint/js": "^9.39.2",
"@faker-js/faker": "^10.1.0",
"@istanbuljs/schema": "^0.1.3",
@@ -74,7 +74,7 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-lit": "^2.2.1",
"eslint-plugin-oxlint": "1.60.0",
"eslint-plugin-oxlint": "1.66.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
@@ -84,7 +84,7 @@
"lint-staged": "^16.0.0",
"msw": "^2.13.2",
"oxlint": "1.58.0",
"oxlint-tsgolint": "^0.19.0",
"oxlint-tsgolint": "^0.23.0",
"prettier": "^3.7.4",
"semver": "^7.7.3",
"typescript": "^5.9.3",
+6
View File
@@ -9,6 +9,7 @@ version = "1.0.0"
crate-type = ["cdylib"]
[dependencies]
aes-gcm = { workspace = true }
affine_common = { workspace = true, features = [
"doc-loader",
"hashcash",
@@ -19,6 +20,7 @@ anyhow = { workspace = true }
base64-simd = { workspace = true }
chrono = { workspace = true }
file-format = { workspace = true }
hex = { workspace = true }
image = { workspace = true }
infer = { workspace = true }
jsonschema = "0.46"
@@ -30,18 +32,22 @@ matroska = { workspace = true }
mp4parse = { workspace = true }
napi = { workspace = true, features = ["async", "serde-json"] }
napi-derive = { workspace = true }
p256 = { workspace = true }
rand = { workspace = true }
reqwest = { version = "0.13.3", default-features = false, features = [
"blocking",
"rustls",
] }
rustls = "0.23"
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
sha3 = { workspace = true }
tiktoken-rs = { workspace = true }
url = { workspace = true }
v_htmlescape = { workspace = true }
webpki-roots = "1"
y-octo = { workspace = true, features = ["large_refs"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
+47
View File
@@ -169,6 +169,8 @@ export interface Chunk {
*/
export declare function createDocWithMarkdown(title: string, markdown: string, docId: string): Buffer
export declare function evaluatePermissionV1(input: any): any
export declare function fetchRemoteAttachment(request: RemoteAttachmentFetchRequest): Promise<RemoteAttachmentFetchResponse>
export declare function fromModelName(modelName: string): Tokenizer | null
@@ -475,6 +477,10 @@ export declare function parsePageDoc(docBin: Buffer, maxSummaryLength?: number |
export declare function parseWorkspaceDoc(docBin: Buffer): NativeWorkspaceDocContent | null
export declare function permissionActionRoleMatrixV1(): any
export declare function permissionActionRoleMatrixV1Json(): string
export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise<Buffer>
export type PromptBuiltin = 'Date'|
@@ -635,6 +641,47 @@ export interface RerankCandidate {
text: string
}
export interface ResolvedEntitlement {
plan: string
valid: boolean
status: string
quantity?: number
expiresAt?: string
subjectId?: string
targetId?: string
recurring?: string
issuedAt?: string
entity?: string
issuer?: string
quota: ResolvedQuota
flags: Record<string, boolean>
errorCode?: string
errorMessage?: string
}
export interface ResolvedQuota {
blobLimit: number
storageQuota: number
seatLimit?: number
seatQuota?: number
historyPeriod: number
copilotActionLimit?: number
}
export interface ResolveEntitlementInput {
deploymentType: string
targetType: string
targetId?: string
plan?: string
quantity?: number
signedPayload?: Buffer
publicKey?: string
licenseAesKey?: string
now: string
}
export declare function resolveEntitlementV1(input: ResolveEntitlementInput): ResolvedEntitlement
export declare function runNativeActionRecipePreparedStream(input: ActionRuntimeInput, callback: ((err: Error | null, arg: string) => void)): LlmStreamHandle
export declare function safeFetch(request: SafeFetchRequest): Promise<SafeFetchResponse>
+726
View File
@@ -0,0 +1,726 @@
use std::collections::HashMap;
use aes_gcm::{
AesGcm, KeyInit,
aead::{
Aead,
generic_array::{GenericArray, typenum::U12},
},
aes::Aes256,
};
use chrono::{DateTime, Utc};
use napi::{Error as NapiError, Result, Status, bindgen_prelude::Buffer};
use napi_derive::napi;
use p256::{
ecdsa::{Signature, VerifyingKey, signature::Verifier},
pkcs8::DecodePublicKey,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
type Aes256Gcm12 = AesGcm<Aes256, U12, U12>;
type LicenseError = (&'static str, &'static str);
type LicenseResult<T> = std::result::Result<T, LicenseError>;
const ONE_MB: i64 = 1024 * 1024;
const ONE_GB: i64 = 1024 * ONE_MB;
const ONE_DAY_SECONDS: i64 = 24 * 60 * 60;
const MAX_SEAT_QUANTITY: i32 = 100_000;
#[napi(object)]
pub struct ResolveEntitlementInput {
pub deployment_type: String,
pub target_type: String,
pub target_id: Option<String>,
pub plan: Option<String>,
#[napi(ts_type = "number")]
pub quantity: Option<Value>,
pub signed_payload: Option<Buffer>,
pub public_key: Option<String>,
pub license_aes_key: Option<String>,
pub now: String,
}
#[derive(Debug)]
#[napi(object)]
pub struct ResolvedQuota {
pub blob_limit: i64,
pub storage_quota: i64,
pub seat_limit: Option<i32>,
pub seat_quota: Option<i64>,
pub history_period: i64,
pub copilot_action_limit: Option<i32>,
}
#[derive(Debug)]
#[napi(object)]
pub struct ResolvedEntitlement {
pub plan: String,
pub valid: bool,
pub status: String,
pub quantity: Option<i32>,
pub expires_at: Option<String>,
pub subject_id: Option<String>,
pub target_id: Option<String>,
pub recurring: Option<String>,
pub issued_at: Option<String>,
pub entity: Option<String>,
pub issuer: Option<String>,
pub quota: ResolvedQuota,
pub flags: HashMap<String, bool>,
pub error_code: Option<String>,
pub error_message: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LicenseEnvelope {
payload: String,
signature: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LicensePayload {
entity: String,
issuer: String,
issued_at: String,
expires_at: String,
data: LicenseData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LicenseData {
id: String,
workspace_id: String,
plan: String,
recurring: String,
quantity: i32,
end_at: String,
}
struct PlanQuota {
name: &'static str,
blob_limit: i64,
storage_quota: i64,
history_period: i64,
member_limit: Option<i32>,
seat_quota: Option<i64>,
copilot_action_limit: Option<i32>,
unlimited_copilot: bool,
}
#[napi]
pub fn resolve_entitlement_v1(input: ResolveEntitlementInput) -> Result<ResolvedEntitlement> {
validate_input(&input)?;
let now = parse_time(&input.now)?;
if input.signed_payload.is_some() {
if input.deployment_type != "selfhosted" || input.target_type != "workspace" {
return invalid_arg("signedPayload is only supported for selfhosted workspace entitlements");
}
return resolve_selfhost_license(input, now);
}
let plan = input.plan.as_deref().unwrap_or_else(|| {
if input.deployment_type == "selfhosted" {
"selfhost_free"
} else {
"free"
}
});
if input.deployment_type == "selfhosted" && plan != "selfhost_free" {
return invalid_arg("selfhosted commercial entitlements require signedPayload");
}
let quantity = parse_quantity(input.quantity.as_ref())?;
Ok(active(plan, quantity, None))
}
fn validate_input(input: &ResolveEntitlementInput) -> Result<()> {
if !matches!(input.deployment_type.as_str(), "cloud" | "selfhosted") {
return invalid_arg("deploymentType must be cloud or selfhosted");
}
if !matches!(input.target_type.as_str(), "user" | "workspace" | "instance") {
return invalid_arg("targetType must be user, workspace, or instance");
}
parse_quantity(input.quantity.as_ref())?;
Ok(())
}
fn parse_quantity(quantity: Option<&Value>) -> Result<Option<i32>> {
let Some(quantity) = quantity else {
return Ok(None);
};
let Some(quantity) = quantity.as_i64() else {
return invalid_arg("quantity must be an integer");
};
if quantity <= 0 || quantity > MAX_SEAT_QUANTITY as i64 {
return invalid_arg("quantity must be between 1 and 100000");
}
Ok(Some(quantity as i32))
}
fn resolve_selfhost_license(input: ResolveEntitlementInput, now: DateTime<Utc>) -> Result<ResolvedEntitlement> {
let Some(payload) = input.signed_payload else {
return Ok(active("selfhost_free", None, None));
};
let Some(public_key) = input.public_key else {
return invalid_arg("publicKey is required for signed payload verification");
};
let Some(license_aes_key) = input.license_aes_key else {
return invalid_arg("licenseAesKey is required for signed payload verification");
};
let payload = match decrypt_license(payload.as_ref(), &license_aes_key)
.and_then(|decrypted| verify_license(&decrypted, &public_key))
{
Ok(payload) => payload,
Err((code, message)) => return Ok(invalid_license(code, message)),
};
if let Err((code, message)) = validate_license_payload(&payload) {
return Ok(invalid_license(code, message));
}
if payload.data.plan != "selfhostedteam" {
return Ok(invalid_license("invalid_payload", "license plan is not selfhostedteam"));
}
if let Some(target_id) = input.target_id.as_deref()
&& target_id != payload.data.workspace_id.as_str()
{
return Ok(invalid_license(
"workspace_mismatch",
"workspace mismatched with license",
));
}
if payload.issued_at.is_empty() || payload.entity.is_empty() || payload.issuer.is_empty() {
return Ok(invalid_license("invalid_payload", "license payload is incomplete"));
}
let file_expires_at = match parse_time(&payload.expires_at) {
Ok(time) => time,
Err(_) => return Ok(invalid_license("invalid_payload", "invalid expiresAt")),
};
let license_expires_at = match parse_time(&payload.data.end_at) {
Ok(time) => time,
Err(_) => return Ok(invalid_license("invalid_payload", "invalid endAt")),
};
let expires_at = file_expires_at.min(license_expires_at);
if expires_at < now {
let mut entitlement = expired(
"selfhost_team",
Some(payload.data.quantity),
Some(expires_at.to_rfc3339()),
);
entitlement.error_code = Some(
if license_expires_at < now && license_expires_at <= file_expires_at {
"expired_end_at"
} else {
"expired"
}
.to_string(),
);
fill_license_metadata(&mut entitlement, &payload);
return Ok(entitlement);
}
let mut entitlement = active(
"selfhost_team",
Some(payload.data.quantity),
Some(expires_at.to_rfc3339()),
);
fill_license_metadata(&mut entitlement, &payload);
Ok(entitlement)
}
fn fill_license_metadata(entitlement: &mut ResolvedEntitlement, payload: &LicensePayload) {
entitlement.subject_id = Some(payload.data.id.clone());
entitlement.target_id = Some(payload.data.workspace_id.clone());
entitlement.recurring = Some(payload.data.recurring.clone());
entitlement.issued_at = Some(payload.issued_at.clone());
entitlement.entity = Some(payload.entity.clone());
entitlement.issuer = Some(payload.issuer.clone());
}
fn validate_license_payload(payload: &LicensePayload) -> LicenseResult<()> {
if payload.data.id.is_empty()
|| payload.data.workspace_id.is_empty()
|| !matches!(payload.data.recurring.as_str(), "monthly" | "yearly" | "lifetime")
|| payload.data.quantity <= 0
|| payload.data.quantity > MAX_SEAT_QUANTITY
{
return Err(("invalid_payload", "license payload is incomplete"));
}
Ok(())
}
fn decrypt_license(buf: &[u8], aes_key: &str) -> LicenseResult<(Vec<u8>, Vec<u8>)> {
if buf.len() < 2 {
return Err(("invalid_file", "invalid license file"));
}
let iv_len = buf[0] as usize;
let tag_len = buf[1] as usize;
let payload_start = 2 + iv_len + tag_len;
if iv_len != 12 || tag_len != 12 || buf.len() <= payload_start {
return Err(("invalid_file", "invalid license file"));
}
let iv = &buf[2..2 + iv_len];
let tag = &buf[2 + iv_len..payload_start];
let payload = &buf[payload_start..];
let key = license_aes_key(aes_key)?;
let cipher = Aes256Gcm12::new_from_slice(&key).map_err(|_| ("invalid_key", "invalid aes key"))?;
let nonce = GenericArray::from_slice(iv);
let mut encrypted = Vec::with_capacity(payload.len() + tag.len());
encrypted.extend_from_slice(payload);
encrypted.extend_from_slice(tag);
let decrypted = cipher
.decrypt(nonce, encrypted.as_ref())
.map_err(|_| ("decrypt_failed", "failed to verify the license"))?;
Ok((iv.to_vec(), decrypted))
}
fn license_aes_key(aes_key: &str) -> LicenseResult<[u8; 32]> {
if aes_key.len() == 64
&& let Ok(decoded) = hex::decode(aes_key)
&& decoded.len() == 32
{
let mut key = [0; 32];
key.copy_from_slice(&decoded);
return Ok(key);
}
Ok(Sha256::digest(aes_key.as_bytes()).into())
}
fn verify_license(decrypted: &(Vec<u8>, Vec<u8>), public_key: &str) -> LicenseResult<LicensePayload> {
let (iv, decrypted) = decrypted;
let envelope: LicenseEnvelope =
serde_json::from_slice(decrypted).map_err(|_| ("invalid_file", "invalid license file"))?;
let signature = hex::decode(&envelope.signature).map_err(|_| ("invalid_signature", "invalid license signature"))?;
let signature = Signature::from_der(&signature).map_err(|_| ("invalid_signature", "invalid license signature"))?;
let verifying_key =
VerifyingKey::from_public_key_pem(public_key).map_err(|_| ("invalid_public_key", "invalid public key"))?;
let mut message = Vec::with_capacity(iv.len() + envelope.payload.len());
message.extend_from_slice(iv);
message.extend_from_slice(envelope.payload.as_bytes());
verifying_key
.verify(&message, &signature)
.map_err(|_| ("invalid_signature", "invalid license signature"))?;
serde_json::from_str::<LicensePayload>(&envelope.payload).map_err(|_| ("invalid_payload", "invalid license payload"))
}
fn active(plan: &str, quantity: Option<i32>, expires_at: Option<String>) -> ResolvedEntitlement {
let quantity = quantity_for_plan(plan, quantity);
let catalog = plan_catalog(plan, quantity);
ResolvedEntitlement {
plan: catalog.name.to_string(),
valid: true,
status: "active".to_string(),
quantity,
expires_at,
subject_id: None,
target_id: None,
recurring: None,
issued_at: None,
entity: None,
issuer: None,
quota: quota(&catalog),
flags: flags(&catalog),
error_code: None,
error_message: None,
}
}
fn expired(plan: &str, quantity: Option<i32>, expires_at: Option<String>) -> ResolvedEntitlement {
let quantity = quantity_for_plan(plan, quantity);
let catalog = plan_catalog(plan, quantity);
ResolvedEntitlement {
plan: catalog.name.to_string(),
valid: false,
status: "expired".to_string(),
quantity,
expires_at,
subject_id: None,
target_id: None,
recurring: None,
issued_at: None,
entity: None,
issuer: None,
quota: quota(&catalog),
flags: flags(&catalog),
error_code: Some("expired".to_string()),
error_message: Some("license expired".to_string()),
}
}
fn invalid_license(code: &'static str, message: &'static str) -> ResolvedEntitlement {
let catalog = plan_catalog("selfhost_free", None);
ResolvedEntitlement {
plan: catalog.name.to_string(),
valid: false,
status: "needs_reupload".to_string(),
quantity: None,
expires_at: None,
subject_id: None,
target_id: None,
recurring: None,
issued_at: None,
entity: None,
issuer: None,
quota: quota(&catalog),
flags: flags(&catalog),
error_code: Some(code.to_string()),
error_message: Some(message.to_string()),
}
}
fn quantity_for_plan(plan: &str, quantity: Option<i32>) -> Option<i32> {
if matches!(plan, "team" | "selfhost_team") {
quantity
} else {
None
}
}
fn plan_catalog(plan: &str, quantity: Option<i32>) -> PlanQuota {
let seats = quantity.unwrap_or(1);
match plan {
"pro" => PlanQuota {
name: "pro",
blob_limit: 100 * ONE_MB,
storage_quota: 100 * ONE_GB,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(10),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
"lifetime_pro" => PlanQuota {
name: "lifetime_pro",
blob_limit: 100 * ONE_MB,
storage_quota: 1024 * ONE_GB,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(10),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
"ai" => PlanQuota {
name: "ai",
blob_limit: 10 * ONE_MB,
storage_quota: 10 * ONE_GB,
history_period: 7 * ONE_DAY_SECONDS,
member_limit: Some(3),
seat_quota: None,
copilot_action_limit: None,
unlimited_copilot: true,
},
"team" | "selfhost_team" => {
let seat_quota = 20 * ONE_GB;
let storage_quota = (seats as i64)
.checked_mul(seat_quota)
.and_then(|storage| storage.checked_add(100 * ONE_GB))
.unwrap_or(i64::MAX);
PlanQuota {
name: if plan == "team" { "team" } else { "selfhost_team" },
blob_limit: 500 * ONE_MB,
storage_quota,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(seats),
seat_quota: Some(seat_quota),
copilot_action_limit: None,
unlimited_copilot: false,
}
}
"selfhost_free" => PlanQuota {
name: "selfhost_free",
blob_limit: 100 * ONE_MB,
storage_quota: 100 * ONE_GB,
history_period: 30 * ONE_DAY_SECONDS,
member_limit: Some(10),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
_ => PlanQuota {
name: "free",
blob_limit: 10 * ONE_MB,
storage_quota: 10 * ONE_GB,
history_period: 7 * ONE_DAY_SECONDS,
member_limit: Some(3),
seat_quota: None,
copilot_action_limit: Some(10),
unlimited_copilot: false,
},
}
}
fn quota(catalog: &PlanQuota) -> ResolvedQuota {
ResolvedQuota {
blob_limit: catalog.blob_limit,
storage_quota: catalog.storage_quota,
seat_limit: catalog.member_limit,
seat_quota: catalog.seat_quota,
history_period: catalog.history_period,
copilot_action_limit: catalog.copilot_action_limit,
}
}
fn flags(catalog: &PlanQuota) -> HashMap<String, bool> {
let mut flags = HashMap::new();
flags.insert("unlimitedCopilot".to_string(), catalog.unlimited_copilot);
flags
}
fn parse_time(value: &str) -> Result<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.map(|value| value.with_timezone(&Utc))
.map_err(|err| NapiError::new(Status::InvalidArg, err.to_string()))
}
fn invalid_arg<T>(message: &'static str) -> Result<T> {
Err(NapiError::new(Status::InvalidArg, message))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_WORKSPACE_ID: &str = "d6f52bc7-d62a-4822-804a-335fa7dfe5a6";
#[rustfmt::skip]
const TEST_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\n\
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqrxlczPknUuj4q4xx1VGr063Cgu7\n\
Hc3w7v4FGmoA5MNzzhrkho1ckDYw2wrX6zBnehFzcivURv80HherE2GQjg==\n\
-----END PUBLIC KEY-----";
const TEST_LICENSE_AES_KEY: &str = "TEST_LICENSE_AES_KEY";
fn input(plan: Option<&str>, quantity: Option<i32>) -> ResolveEntitlementInput {
ResolveEntitlementInput {
deployment_type: "cloud".to_string(),
target_type: "workspace".to_string(),
target_id: Some("workspace".to_string()),
plan: plan.map(str::to_string),
quantity: quantity.map(Value::from),
signed_payload: None,
public_key: None,
license_aes_key: None,
now: "2026-05-14T00:00:00Z".to_string(),
}
}
fn license_input(file: &str, workspace_id: &str) -> ResolveEntitlementInput {
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../server/src/__tests__/e2e/license/__fixtures__")
.join(file);
ResolveEntitlementInput {
deployment_type: "selfhosted".to_string(),
target_type: "workspace".to_string(),
target_id: Some(workspace_id.to_string()),
plan: None,
quantity: None,
signed_payload: Some(std::fs::read(fixture).unwrap().into()),
public_key: Some(TEST_PUBLIC_KEY.to_string()),
license_aes_key: Some(TEST_LICENSE_AES_KEY.to_string()),
now: "2026-05-14T00:00:00Z".to_string(),
}
}
fn decrypted_license(file: &str) -> (Vec<u8>, Vec<u8>) {
let input = license_input(file, TEST_WORKSPACE_ID);
let payload = input.signed_payload.unwrap();
decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap()
}
#[test]
fn decrypts_license_with_raw_or_hashed_aes_key() {
let input = license_input("valid.license", TEST_WORKSPACE_ID);
let payload = input.signed_payload.unwrap();
let hashed_key = hex::encode(Sha256::digest(TEST_LICENSE_AES_KEY.as_bytes()));
let raw = decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap();
let hashed = decrypt_license(payload.as_ref(), &hashed_key).unwrap();
assert_eq!(raw.0, hashed.0);
assert_eq!(raw.1, hashed.1);
}
#[test]
fn derives_plan_quota() {
let cases = [
("free", None, 3, 10 * ONE_GB, Some(10)),
("pro", None, 10, 100 * ONE_GB, Some(10)),
("lifetime_pro", None, 10, 1024 * ONE_GB, Some(10)),
("team", Some(5), 5, 200 * ONE_GB, None),
("selfhost_team", Some(20), 20, 500 * ONE_GB, None),
("selfhost_free", None, 10, 100 * ONE_GB, Some(10)),
];
for (plan, quantity, seat_limit, storage_quota, copilot_limit) in cases {
let mut input = input(Some(plan), quantity);
if plan == "selfhost_free" {
input.deployment_type = "selfhosted".to_string();
}
let resolved = resolve_entitlement_v1(input).unwrap();
assert!(resolved.valid, "{plan}");
assert_eq!(
resolved.quantity,
if matches!(plan, "team" | "selfhost_team") {
quantity
} else {
None
},
"{plan}"
);
assert_eq!(resolved.quota.seat_limit, Some(seat_limit), "{plan}");
assert_eq!(resolved.quota.storage_quota, storage_quota, "{plan}");
assert_eq!(resolved.quota.copilot_action_limit, copilot_limit, "{plan}");
}
}
#[test]
fn ignores_quantity_for_fixed_catalog_plans() {
for plan in ["free", "pro", "lifetime_pro", "ai", "selfhost_free"] {
let mut input = input(Some(plan), Some(50));
if plan == "selfhost_free" {
input.deployment_type = "selfhosted".to_string();
}
let resolved = resolve_entitlement_v1(input).unwrap();
assert_eq!(resolved.quantity, None, "{plan}");
assert_ne!(resolved.quota.seat_limit, Some(50), "{plan}");
}
}
#[test]
fn rejects_invalid_quantity() {
for quantity in [0, -1, MAX_SEAT_QUANTITY + 1] {
let err = resolve_entitlement_v1(input(Some("team"), Some(quantity))).unwrap_err();
assert_eq!(err.status, Status::InvalidArg, "{quantity}");
}
}
#[test]
fn rejects_unsigned_selfhosted_commercial_entitlements() {
for plan in ["pro", "lifetime_pro", "ai", "team", "selfhost_team"] {
let mut input = input(Some(plan), Some(50));
input.deployment_type = "selfhosted".to_string();
let err = resolve_entitlement_v1(input).unwrap_err();
assert_eq!(err.status, Status::InvalidArg, "{plan}");
}
}
#[test]
fn rejects_schema_errors() {
let mut input = input(Some("free"), None);
input.deployment_type = "local".to_string();
let err = resolve_entitlement_v1(input).unwrap_err();
assert_eq!(err.status, Status::InvalidArg);
}
#[test]
fn rejects_signed_payload_outside_selfhost_workspace_boundary() {
let cases = [
("cloud", "workspace"),
("selfhosted", "user"),
("selfhosted", "instance"),
];
for (deployment_type, target_type) in cases {
let mut input = license_input("valid.license", TEST_WORKSPACE_ID);
input.deployment_type = deployment_type.to_string();
input.target_type = target_type.to_string();
let err = resolve_entitlement_v1(input).unwrap_err();
assert_eq!(err.status, Status::InvalidArg, "{deployment_type}/{target_type}");
}
}
#[test]
fn verifies_selfhost_license_files() {
let cases = [
("valid.license", TEST_WORKSPACE_ID, true, "active", None, Some(20)),
(
"valid.license",
"other-workspace",
false,
"needs_reupload",
Some("workspace_mismatch"),
None,
),
(
"expired.license",
TEST_WORKSPACE_ID,
false,
"expired",
Some("expired"),
Some(20),
),
(
"expired-end-at.license",
TEST_WORKSPACE_ID,
false,
"expired",
Some("expired_end_at"),
Some(20),
),
];
for (file, workspace_id, valid, status, error_code, quantity) in cases {
let resolved = resolve_entitlement_v1(license_input(file, workspace_id)).unwrap();
assert_eq!(resolved.valid, valid, "{file}");
assert_eq!(resolved.status, status, "{file}");
assert_eq!(resolved.error_code.as_deref(), error_code, "{file}");
assert_eq!(resolved.quantity, quantity, "{file}");
if valid {
assert_eq!(resolved.plan, "selfhost_team", "{file}");
assert_eq!(resolved.quota.seat_limit, quantity, "{file}");
assert_eq!(resolved.quota.storage_quota, 500 * ONE_GB, "{file}");
assert_eq!(resolved.quota.blob_limit, 500 * ONE_MB, "{file}");
}
}
}
#[test]
fn verifies_signature_branch() {
let (iv, decrypted) = decrypted_license("valid.license");
let mut envelope: LicenseEnvelope = serde_json::from_slice(&decrypted).unwrap();
envelope.signature = "00".to_string();
let decrypted = serde_json::to_vec(&envelope).unwrap();
let err = verify_license(&(iv, decrypted), TEST_PUBLIC_KEY).unwrap_err();
assert_eq!(err.0, "invalid_signature");
}
#[test]
fn rejects_license_payload_schema_and_quantity_errors() {
let mut payload: LicensePayload = serde_json::from_str(
&serde_json::from_slice::<LicenseEnvelope>(&decrypted_license("valid.license").1)
.unwrap()
.payload,
)
.unwrap();
for quantity in [0, -1] {
payload.data.quantity = quantity;
let err = validate_license_payload(&payload).unwrap_err();
assert_eq!(err.0, "invalid_payload");
}
payload.data.quantity = 20;
payload.data.workspace_id.clear();
let err = validate_license_payload(&payload).unwrap_err();
assert_eq!(err.0, "invalid_payload");
}
}
+2
View File
@@ -4,11 +4,13 @@ mod utils;
pub mod doc;
pub mod doc_loader;
pub mod entitlement;
pub mod file_type;
pub mod hashcash;
pub mod html_sanitize;
pub mod image;
pub mod llm;
pub mod permission;
pub mod safe_fetch;
pub mod tiktoken;
@@ -706,8 +706,8 @@
"optionalModels": [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3.1-pro-preview",
"claude-sonnet-4-5@20250929"
"gemini-3.5-flash",
"claude-sonnet-4-6"
],
"config": {
"tools": [
@@ -722,11 +722,7 @@
"codeArtifact",
"blobRead"
],
"proModels": [
"gemini-2.5-pro",
"gemini-3.1-pro-preview",
"claude-sonnet-4-5@20250929"
]
"proModels": ["gemini-2.5-pro", "gemini-3.5-flash", "claude-sonnet-4-6"]
},
"builtins": [
"date",
@@ -61,12 +61,12 @@ mod tests {
fn should_resolve_backend_scoped_alias() {
let response = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
backend_kind: Some("anthropic_vertex".to_string()),
model_id: "claude-sonnet-4.5".to_string(),
model_id: "claude-sonnet-4.6".to_string(),
})
.unwrap();
assert_eq!(response.matched_by.as_deref(), Some("canonical"));
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-5@20250929");
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-6");
}
#[test]
@@ -111,6 +111,21 @@ mod tests {
assert_eq!(response.variant.unwrap().raw_model_id, "gemini-embedding-001");
}
#[test]
fn should_resolve_gemini_embedding_2() {
let response = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
backend_kind: Some("gemini_api".to_string()),
model_id: "gemini-embedding-2".to_string(),
})
.unwrap();
let variant = response.variant.unwrap();
assert_eq!(variant.raw_model_id, "gemini-embedding-2");
assert_eq!(variant.protocol.as_deref(), Some("gemini"));
assert_eq!(variant.request_layer.as_deref(), Some("gemini_api"));
assert_eq!(variant.display_name.as_deref(), Some("Gemini Embedding 2"));
}
#[test]
fn should_keep_same_raw_id_as_two_backend_variants() {
let api_variant = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
@@ -0,0 +1,255 @@
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use super::types::{DocRole, WorkspaceRole};
pub(super) const VERSION: u32 = 1;
const WORKSPACE_EXTERNAL_ACTIONS: &[&str] = &[
"Workspace.Read",
"Workspace.Organize.Read",
"Workspace.Properties.Read",
"Workspace.Blobs.Read",
];
const WORKSPACE_MEMBER_ACTIONS: &[&str] = &[
"Workspace.Sync",
"Workspace.CreateDoc",
"Workspace.Users.Read",
"Workspace.Settings.Read",
"Workspace.Blobs.Write",
"Workspace.Blobs.List",
"Workspace.Copilot",
];
const WORKSPACE_ADMIN_ACTIONS: &[&str] = &[
"Workspace.Users.Manage",
"Workspace.Settings.Update",
"Workspace.Properties.Create",
"Workspace.Properties.Update",
"Workspace.Properties.Delete",
];
const WORKSPACE_OWNER_ACTIONS: &[&str] = &[
"Workspace.Delete",
"Workspace.Administrators.Manage",
"Workspace.TransferOwner",
"Workspace.Payment.Manage",
];
const DOC_EXTERNAL_ACTIONS: &[&str] = &["Doc.Read", "Doc.Copy", "Doc.Properties.Read", "Doc.Comments.Read"];
const DOC_READER_ACTIONS: &[&str] = &["Doc.Users.Read", "Doc.Duplicate"];
const DOC_COMMENTER_ACTIONS: &[&str] = &["Doc.Comments.Create"];
const DOC_EDITOR_ACTIONS: &[&str] = &[
"Doc.Trash",
"Doc.Restore",
"Doc.Delete",
"Doc.Properties.Update",
"Doc.Update",
"Doc.Comments.Update",
"Doc.Comments.Resolve",
"Doc.Comments.Delete",
];
const DOC_MANAGER_ACTIONS: &[&str] = &["Doc.Publish", "Doc.Users.Manage"];
const DOC_OWNER_ACTIONS: &[&str] = &["Doc.TransferOwner"];
pub(super) const WORKSPACE_PREVIEW_ACTION: &str = "Workspace.Preview";
pub(super) const DOC_PREVIEW_ACTION: &str = "Doc.Preview";
const WORKSPACE_WRITE_ACTIONS: &[&str] = &[
"Workspace.Sync",
"Workspace.CreateDoc",
"Workspace.Delete",
"Workspace.TransferOwner",
"Workspace.Users.Manage",
"Workspace.Administrators.Manage",
"Workspace.Properties.Create",
"Workspace.Properties.Update",
"Workspace.Properties.Delete",
"Workspace.Settings.Update",
"Workspace.Blobs.Write",
"Workspace.Payment.Manage",
];
const DOC_WRITE_ACTIONS: &[&str] = &[
"Doc.Duplicate",
"Doc.Trash",
"Doc.Restore",
"Doc.Delete",
"Doc.Update",
"Doc.Publish",
"Doc.TransferOwner",
"Doc.Properties.Update",
"Doc.Users.Manage",
"Doc.Comments.Create",
"Doc.Comments.Update",
"Doc.Comments.Delete",
"Doc.Comments.Resolve",
];
fn action_set(groups: &[&[&str]]) -> BTreeSet<String> {
groups
.iter()
.flat_map(|group| group.iter().copied())
.map(str::to_string)
.collect()
}
pub(super) fn workspace_actions_for_role(role: WorkspaceRole) -> BTreeSet<String> {
match role {
WorkspaceRole::External => action_set(&[WORKSPACE_EXTERNAL_ACTIONS]),
WorkspaceRole::Member => action_set(&[WORKSPACE_EXTERNAL_ACTIONS, WORKSPACE_MEMBER_ACTIONS]),
WorkspaceRole::Admin => action_set(&[
WORKSPACE_EXTERNAL_ACTIONS,
WORKSPACE_MEMBER_ACTIONS,
WORKSPACE_ADMIN_ACTIONS,
]),
WorkspaceRole::Owner => action_set(&[
WORKSPACE_EXTERNAL_ACTIONS,
WORKSPACE_MEMBER_ACTIONS,
WORKSPACE_ADMIN_ACTIONS,
WORKSPACE_OWNER_ACTIONS,
]),
}
}
pub(super) fn doc_actions_for_role(role: DocRole) -> BTreeSet<String> {
match role {
DocRole::None => BTreeSet::new(),
DocRole::External => action_set(&[DOC_EXTERNAL_ACTIONS]),
DocRole::Reader => action_set(&[DOC_EXTERNAL_ACTIONS, DOC_READER_ACTIONS]),
DocRole::Commenter => action_set(&[DOC_EXTERNAL_ACTIONS, DOC_READER_ACTIONS, DOC_COMMENTER_ACTIONS]),
DocRole::Editor => action_set(&[
DOC_EXTERNAL_ACTIONS,
DOC_READER_ACTIONS,
DOC_COMMENTER_ACTIONS,
DOC_EDITOR_ACTIONS,
]),
DocRole::Manager => action_set(&[
DOC_EXTERNAL_ACTIONS,
DOC_READER_ACTIONS,
DOC_COMMENTER_ACTIONS,
DOC_EDITOR_ACTIONS,
DOC_MANAGER_ACTIONS,
]),
DocRole::Owner => action_set(&[
DOC_EXTERNAL_ACTIONS,
DOC_READER_ACTIONS,
DOC_COMMENTER_ACTIONS,
DOC_EDITOR_ACTIONS,
DOC_MANAGER_ACTIONS,
DOC_OWNER_ACTIONS,
]),
}
}
pub(super) fn is_write_action(action: &str) -> bool {
WORKSPACE_WRITE_ACTIONS.contains(&action) || DOC_WRITE_ACTIONS.contains(&action)
}
pub(super) fn is_readonly_restricted_action(action: &str) -> bool {
matches!(
action,
"Workspace.CreateDoc"
| "Workspace.Settings.Update"
| "Workspace.Properties.Create"
| "Workspace.Properties.Update"
| "Workspace.Properties.Delete"
| "Workspace.Blobs.Write"
| "Doc.Update"
| "Doc.Duplicate"
| "Doc.Publish"
| "Doc.Comments.Create"
| "Doc.Comments.Update"
| "Doc.Comments.Resolve"
)
}
pub(super) fn role_matrix_json() -> Value {
let workspace_roles = [
("external", WorkspaceRole::External),
("member", WorkspaceRole::Member),
("admin", WorkspaceRole::Admin),
("owner", WorkspaceRole::Owner),
]
.into_iter()
.map(|(name, role)| (name, workspace_actions_for_role(role).into_iter().collect::<Vec<_>>()))
.collect::<BTreeMap<_, _>>();
let doc_roles = [
("none", DocRole::None),
("external", DocRole::External),
("reader", DocRole::Reader),
("commenter", DocRole::Commenter),
("editor", DocRole::Editor),
("manager", DocRole::Manager),
("owner", DocRole::Owner),
]
.into_iter()
.map(|(name, role)| (name, doc_actions_for_role(role).into_iter().collect::<Vec<_>>()))
.collect::<BTreeMap<_, _>>();
json!({
"version": VERSION,
"workspace": {
"roles": workspace_roles,
"capabilityProfiles": {
"workspacePreview": [WORKSPACE_PREVIEW_ACTION],
},
"readonlyWriteActions": {
"restricted": [
"Workspace.CreateDoc",
"Workspace.Settings.Update",
"Workspace.Properties.Create",
"Workspace.Properties.Update",
"Workspace.Properties.Delete",
"Workspace.Blobs.Write",
],
},
},
"doc": {
"roles": doc_roles,
"capabilityProfiles": {
"docPreview": [DOC_PREVIEW_ACTION],
},
"readonlyWriteActions": {
"restricted": [
"Doc.Update",
"Doc.Duplicate",
"Doc.Publish",
"Doc.Comments.Create",
"Doc.Comments.Update",
"Doc.Comments.Resolve",
],
},
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matrix_artifact_exposes_profiles_and_restrictions() {
let artifact = role_matrix_json();
assert_eq!(artifact["version"], 1);
assert_eq!(
artifact["doc"]["capabilityProfiles"]["docPreview"][0],
DOC_PREVIEW_ACTION
);
assert_eq!(
artifact["workspace"]["roles"]["owner"][0],
"Workspace.Administrators.Manage"
);
assert!(
artifact["doc"]["roles"]["external"]
.as_array()
.unwrap()
.contains(&json!("Doc.Read"))
);
}
}
@@ -0,0 +1,309 @@
use std::collections::BTreeSet;
use serde::Serialize;
use super::{
actions::{
DOC_PREVIEW_ACTION, WORKSPACE_PREVIEW_ACTION, doc_actions_for_role, is_readonly_restricted_action, is_write_action,
workspace_actions_for_role,
},
types::{
Candidate, DocRole, PermissionDecisionRestrictionV1, PermissionDecisionSourceV1, PermissionDecisionV1,
PermissionDocInputV1, PermissionEvaluationInputV1, WorkspaceRole,
},
};
pub(super) fn parse_workspace_role(role: &str) -> anyhow::Result<WorkspaceRole> {
match role {
"external" => Ok(WorkspaceRole::External),
"member" => Ok(WorkspaceRole::Member),
"admin" => Ok(WorkspaceRole::Admin),
"owner" => Ok(WorkspaceRole::Owner),
_ => anyhow::bail!("unknown workspace role: {role}"),
}
}
fn parse_doc_role(role: &str) -> anyhow::Result<DocRole> {
match role {
"none" => Ok(DocRole::None),
"external" => Ok(DocRole::External),
"reader" => Ok(DocRole::Reader),
"commenter" => Ok(DocRole::Commenter),
"editor" => Ok(DocRole::Editor),
"manager" => Ok(DocRole::Manager),
"owner" => Ok(DocRole::Owner),
_ => anyhow::bail!("unknown doc role: {role}"),
}
}
pub(super) fn role_name(role: impl Serialize) -> String {
serde_json::to_value(role)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_default()
}
fn active_workspace_role(input: &PermissionEvaluationInputV1) -> anyhow::Result<Option<WorkspaceRole>> {
let Some(role) = input.workspace.role.as_deref() else {
if input.workspace.local && input.subject.allow_local {
return Ok(Some(WorkspaceRole::Owner));
}
if input.workspace.public && sharing_enabled(input, None) {
return Ok(Some(WorkspaceRole::External));
}
return Ok(None);
};
if input.workspace.member_state.as_deref().unwrap_or("active") != "active" {
return Ok(None);
}
let role = parse_workspace_role(role)?;
if role == WorkspaceRole::External {
return Ok(None);
}
Ok(Some(role))
}
fn sharing_enabled(input: &PermissionEvaluationInputV1, doc: Option<&PermissionDocInputV1>) -> bool {
doc
.and_then(|doc| doc.sharing_enabled)
.or(input.runtime.sharing_enabled)
.or(input.workspace.sharing_enabled)
.unwrap_or(true)
}
fn url_preview_enabled(input: &PermissionEvaluationInputV1) -> bool {
input
.runtime
.url_preview_enabled
.or(input.workspace.url_preview_enabled)
.unwrap_or(false)
}
fn restricted_decision(input: &PermissionEvaluationInputV1, action: &str) -> Vec<PermissionDecisionRestrictionV1> {
if !is_write_action(action) {
return Vec::new();
}
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
return Vec::new();
}
let mut restrictions = Vec::new();
if !input.runtime.known {
restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "runtime_unknown",
reason: None,
});
}
if input.runtime.stale {
restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "runtime_stale",
reason: None,
});
}
if input.runtime.readonly && is_readonly_restricted_action(action) {
restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "readonly",
reason: input.runtime.readonly_reason.clone(),
});
}
restrictions
}
pub(super) fn decide(
input: &PermissionEvaluationInputV1,
action: &str,
candidates: &[Candidate],
) -> PermissionDecisionV1 {
let sources = candidates
.iter()
.filter(|candidate| candidate.actions.contains(action))
.map(|candidate| PermissionDecisionSourceV1 {
source_type: candidate.source_type,
role: Some(candidate.role.clone()),
})
.collect::<Vec<_>>();
let restrictions = restricted_decision(input, action);
PermissionDecisionV1 {
action: action.to_string(),
allowed: !sources.is_empty() && restrictions.is_empty(),
sources,
restrictions,
}
}
pub(super) fn decide_doc(
input: &PermissionEvaluationInputV1,
doc: &PermissionDocInputV1,
action: &str,
candidates: &[Candidate],
) -> PermissionDecisionV1 {
let mut decision = decide(input, action, candidates);
if action == "Doc.Publish" && !sharing_enabled(input, Some(doc)) {
decision.restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "sharing-disabled",
reason: None,
});
decision.allowed = false;
}
decision
}
pub(super) fn workspace_candidates(input: &PermissionEvaluationInputV1) -> anyhow::Result<Vec<Candidate>> {
let mut candidates = Vec::new();
if let Some(role) = active_workspace_role(input)? {
candidates.push(Candidate {
source_type: "workspace-member",
role: role_name(role),
actions: workspace_actions_for_role(role),
owner: role == WorkspaceRole::Owner,
});
}
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
candidates.push(Candidate {
source_type: "local-workspace",
role: "owner".to_string(),
actions: workspace_actions_for_role(WorkspaceRole::Owner),
owner: true,
});
}
if input.workspace.public && sharing_enabled(input, None) {
candidates.push(Candidate {
source_type: "workspace-policy",
role: "external".to_string(),
actions: workspace_actions_for_role(WorkspaceRole::External),
owner: false,
});
}
if sharing_enabled(input, None) && (input.workspace.public || url_preview_enabled(input)) {
candidates.push(Candidate {
source_type: "workspace-preview-policy",
role: "preview".to_string(),
actions: BTreeSet::from([WORKSPACE_PREVIEW_ACTION.to_string()]),
owner: false,
});
}
Ok(candidates)
}
pub(super) fn best_doc_role(candidates: &[Candidate]) -> Option<String> {
candidates
.iter()
.filter_map(|candidate| parse_doc_role(&candidate.role).ok())
.filter(|role| *role != DocRole::None)
.max()
.map(role_name)
}
pub(super) fn doc_candidates(
input: &PermissionEvaluationInputV1,
doc: &PermissionDocInputV1,
) -> anyhow::Result<Vec<Candidate>> {
let mut candidates = Vec::new();
let active_workspace_role = active_workspace_role(input)?;
let active_workspace_member = matches!(
active_workspace_role,
Some(WorkspaceRole::Member | WorkspaceRole::Admin | WorkspaceRole::Owner)
);
let sharing = sharing_enabled(input, Some(doc));
match active_workspace_role {
Some(WorkspaceRole::Owner) => candidates.push(Candidate {
source_type: "inherited-workspace-role",
role: "owner".to_string(),
actions: doc_actions_for_role(DocRole::Owner),
owner: false,
}),
Some(WorkspaceRole::Admin) => candidates.push(Candidate {
source_type: "inherited-workspace-role",
role: "manager".to_string(),
actions: doc_actions_for_role(DocRole::Manager),
owner: false,
}),
_ => {}
}
let explicit_user_role = doc
.explicit_user_role
.as_deref()
.map(parse_doc_role)
.transpose()?
.filter(|role| *role != DocRole::None);
if let Some(mut role) = explicit_user_role {
if !active_workspace_member {
role = role.min(DocRole::Editor);
}
if active_workspace_member || sharing {
candidates.push(Candidate {
source_type: "doc-grant",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: role == DocRole::Owner,
});
}
}
if doc.group_grants_enabled && !input.subject.group_ids.is_empty() {
let subject_groups = input.subject.group_ids.iter().collect::<BTreeSet<_>>();
for grant in &doc.group_grants {
if subject_groups.contains(&grant.group_id) {
let role = parse_doc_role(&grant.role)?;
candidates.push(Candidate {
source_type: "group-grant",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: false,
});
}
}
}
if matches!(active_workspace_role, Some(role) if role != WorkspaceRole::External)
&& explicit_user_role.is_none()
&& let Some(role) = doc.member_default_role.as_deref()
{
let role = parse_doc_role(role)?;
candidates.push(Candidate {
source_type: "member-default-policy",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: false,
});
}
if sharing
&& doc.visibility.as_deref() == Some("public")
&& let Some(role) = doc.public_role.as_deref()
{
let role = parse_doc_role(role)?;
candidates.push(Candidate {
source_type: "public-policy",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: false,
});
}
if sharing && (doc.preview_enabled || doc.visibility.as_deref() == Some("public") || url_preview_enabled(input)) {
candidates.push(Candidate {
source_type: "doc-preview-policy",
role: "preview".to_string(),
actions: BTreeSet::from([DOC_PREVIEW_ACTION.to_string()]),
owner: false,
});
}
if !sharing {
for candidate in &mut candidates {
candidate.actions.remove("Doc.Publish");
}
}
Ok(candidates)
}
@@ -0,0 +1,371 @@
use super::{
actions::VERSION,
candidates::{
best_doc_role, decide, decide_doc, doc_candidates, parse_workspace_role, role_name, workspace_candidates,
},
types::{
PermissionDocEvaluationOutputV1, PermissionEvaluationInputV1, PermissionEvaluationOutputV1,
PermissionWorkspaceEvaluationOutputV1,
},
};
pub fn evaluate_permission(input: PermissionEvaluationInputV1) -> anyhow::Result<PermissionEvaluationOutputV1> {
if input.version != VERSION {
anyhow::bail!("unsupported permission evaluation input version: {}", input.version);
}
let workspace_candidates = workspace_candidates(&input)?;
let workspace_decisions = input
.workspace_actions
.iter()
.map(|action| decide(&input, action, &workspace_candidates))
.collect::<Vec<_>>();
let workspace_effective_role = workspace_candidates
.iter()
.filter_map(|candidate| parse_workspace_role(&candidate.role).ok())
.max()
.map(role_name);
let workspace_resource_owner_role = workspace_candidates
.iter()
.any(|candidate| candidate.owner)
.then(|| "owner".to_string());
let mut docs = Vec::with_capacity(input.docs.len());
for doc in &input.docs {
let candidates = doc_candidates(&input, doc)?;
let decisions = doc
.actions
.iter()
.map(|action| decide_doc(&input, doc, action, &candidates))
.collect::<Vec<_>>();
let resource_owner_role = candidates
.iter()
.any(|candidate| candidate.owner)
.then(|| "owner".to_string());
docs.push(PermissionDocEvaluationOutputV1 {
doc_id: doc.doc_id.clone(),
resource_owner_role,
effective_role: best_doc_role(&candidates),
decisions,
});
}
Ok(PermissionEvaluationOutputV1 {
version: VERSION,
workspace: PermissionWorkspaceEvaluationOutputV1 {
resource_owner_role: workspace_resource_owner_role,
effective_role: workspace_effective_role,
decisions: workspace_decisions,
},
docs,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::types::{
PermissionDecisionV1, PermissionDocInputV1, PermissionGroupGrantInputV1, PermissionRuntimeInputV1,
PermissionSubjectInputV1, PermissionWorkspaceInputV1,
};
fn base_input() -> PermissionEvaluationInputV1 {
PermissionEvaluationInputV1 {
version: 1,
legacy_compat_mode: false,
subject: PermissionSubjectInputV1::default(),
runtime: PermissionRuntimeInputV1 {
known: true,
sharing_enabled: Some(true),
..Default::default()
},
workspace: PermissionWorkspaceInputV1 {
role: Some("member".to_string()),
member_state: Some("active".to_string()),
sharing_enabled: Some(true),
..Default::default()
},
workspace_actions: vec!["Workspace.Read".to_string(), "Workspace.CreateDoc".to_string()],
docs: vec![PermissionDocInputV1 {
doc_id: "doc".to_string(),
actions: vec!["Doc.Read".to_string(), "Doc.Update".to_string()],
member_default_role: Some("manager".to_string()),
..Default::default()
}],
}
}
fn decision<'a>(decisions: &'a [PermissionDecisionV1], action: &str) -> &'a PermissionDecisionV1 {
decisions.iter().find(|decision| decision.action == action).unwrap()
}
#[test]
fn active_member_role_authorizes_workspace_and_doc_default() {
let output = evaluate_permission(base_input()).unwrap();
assert!(decision(&output.workspace.decisions, "Workspace.Read").allowed);
assert!(decision(&output.workspace.decisions, "Workspace.CreateDoc").allowed);
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
}
#[test]
fn pending_and_waiting_members_do_not_authorize() {
for state in ["pending", "waiting_review", "waiting_seat"] {
let mut input = base_input();
input.workspace.member_state = Some(state.to_string());
let output = evaluate_permission(input).unwrap();
assert!(!decision(&output.workspace.decisions, "Workspace.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
}
}
#[test]
fn owner_and_admin_inherit_doc_permissions_without_doc_ownership_pollution() {
let mut owner_input = base_input();
owner_input.workspace.role = Some("owner".to_string());
owner_input.docs[0].actions = vec!["Doc.TransferOwner".to_string()];
let owner_output = evaluate_permission(owner_input).unwrap();
let owner_doc = &owner_output.docs[0];
assert!(decision(&owner_doc.decisions, "Doc.TransferOwner").allowed);
assert_eq!(owner_doc.resource_owner_role, None);
assert_eq!(owner_doc.effective_role.as_deref(), Some("owner"));
let mut admin_input = base_input();
admin_input.workspace.role = Some("admin".to_string());
admin_input.docs[0].actions = vec!["Doc.Users.Manage".to_string(), "Doc.TransferOwner".to_string()];
let admin_output = evaluate_permission(admin_input).unwrap();
assert!(decision(&admin_output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&admin_output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(admin_output.docs[0].resource_owner_role, None);
}
#[test]
fn explicit_doc_grant_sets_resource_owner_only_for_owner_grant() {
let mut input = base_input();
input.docs[0].explicit_user_role = Some("reader".to_string());
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert_eq!(output.docs[0].resource_owner_role, None);
let mut owner_input = base_input();
owner_input.docs[0].explicit_user_role = Some("owner".to_string());
owner_input.docs[0].actions = vec!["Doc.TransferOwner".to_string()];
let owner_output = evaluate_permission(owner_input).unwrap();
assert!(decision(&owner_output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(owner_output.docs[0].resource_owner_role.as_deref(), Some("owner"));
}
#[test]
fn explicit_none_legacy_row_behaves_like_missing_grant() {
let mut input = base_input();
input.docs[0].explicit_user_role = Some("none".to_string());
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].actions = vec!["Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
let update = decision(&output.docs[0].decisions, "Doc.Update");
assert!(update.allowed);
assert_eq!(update.sources[0].source_type, "member-default-policy");
}
#[test]
fn non_member_explicit_doc_grant_is_capped_at_editor() {
let mut input = base_input();
input.workspace.role = None;
input.docs[0].explicit_user_role = Some("owner".to_string());
input.docs[0].actions = vec![
"Doc.Update".to_string(),
"Doc.Users.Manage".to_string(),
"Doc.TransferOwner".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
assert_eq!(output.docs[0].resource_owner_role, None);
}
#[test]
fn legacy_external_workspace_row_does_not_uncap_explicit_doc_grant() {
let mut input = base_input();
input.workspace.role = Some("external".to_string());
input.docs[0].explicit_user_role = Some("owner".to_string());
input.docs[0].actions = vec![
"Doc.Update".to_string(),
"Doc.Users.Manage".to_string(),
"Doc.TransferOwner".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
assert_eq!(output.docs[0].resource_owner_role, None);
}
#[test]
fn public_workspace_policy_does_not_uncap_explicit_doc_grant() {
let mut input = base_input();
input.workspace.role = None;
input.workspace.public = true;
input.docs[0].explicit_user_role = Some("owner".to_string());
input.docs[0].actions = vec![
"Doc.Update".to_string(),
"Doc.Users.Manage".to_string(),
"Doc.TransferOwner".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
assert_eq!(output.docs[0].resource_owner_role, None);
}
#[test]
fn member_default_none_unions_with_public_policy() {
let mut input = base_input();
input.docs[0].member_default_role = Some("none".to_string());
input.docs[0].visibility = Some("public".to_string());
input.docs[0].public_role = Some("external".to_string());
input.docs[0].actions = vec![
"Doc.Read".to_string(),
"Doc.Users.Read".to_string(),
"Doc.Duplicate".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Duplicate").allowed);
}
#[test]
fn public_doc_external_profile_and_url_preview_do_not_grant_read() {
let mut public_input = base_input();
public_input.workspace.role = None;
public_input.docs[0].visibility = Some("public".to_string());
public_input.docs[0].public_role = Some("external".to_string());
public_input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Users.Read".to_string()];
let public_output = evaluate_permission(public_input).unwrap();
assert!(decision(&public_output.docs[0].decisions, "Doc.Read").allowed);
assert!(!decision(&public_output.docs[0].decisions, "Doc.Users.Read").allowed);
let mut preview_input = base_input();
preview_input.workspace.role = None;
preview_input.runtime.url_preview_enabled = Some(true);
preview_input.docs[0].actions = vec!["Doc.Preview".to_string(), "Doc.Read".to_string()];
let preview_output = evaluate_permission(preview_input).unwrap();
assert!(decision(&preview_output.docs[0].decisions, "Doc.Preview").allowed);
assert!(!decision(&preview_output.docs[0].decisions, "Doc.Read").allowed);
}
#[test]
fn public_workspace_shell_does_not_grant_private_doc_read() {
let mut input = base_input();
input.workspace.role = None;
input.workspace.public = true;
input.workspace_actions = vec!["Workspace.Read".to_string()];
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].visibility = Some("private".to_string());
input.docs[0].public_role = None;
input.docs[0].actions = vec!["Doc.Read".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.workspace.decisions, "Workspace.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
}
#[test]
fn sharing_disabled_blocks_public_and_non_member_explicit_sources() {
let mut input = base_input();
input.workspace.role = None;
input.runtime.sharing_enabled = Some(false);
input.docs[0].visibility = Some("public".to_string());
input.docs[0].public_role = Some("external".to_string());
input.docs[0].explicit_user_role = Some("reader".to_string());
input.docs[0].actions = vec!["Doc.Read".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
}
#[test]
fn doc_publish_requires_sharing_enabled() {
let mut input = base_input();
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].actions = vec!["Doc.Publish".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Publish").allowed);
let mut disabled_input = base_input();
disabled_input.runtime.sharing_enabled = Some(false);
disabled_input.docs[0].member_default_role = Some("manager".to_string());
disabled_input.docs[0].actions = vec!["Doc.Publish".to_string()];
let disabled_output = evaluate_permission(disabled_input).unwrap();
let publish = decision(&disabled_output.docs[0].decisions, "Doc.Publish");
assert!(!publish.allowed);
assert_eq!(publish.restrictions[0].restriction_type, "sharing-disabled");
}
#[test]
fn readonly_and_unknown_runtime_fail_closed_for_write_actions() {
let mut input = base_input();
input.runtime.readonly = true;
input.runtime.readonly_reason = Some("storage_overflow".to_string());
input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
let update = decision(&output.docs[0].decisions, "Doc.Update");
assert!(!update.allowed);
assert_eq!(update.restrictions[0].restriction_type, "readonly");
let mut unknown_input = base_input();
unknown_input.runtime.known = false;
let unknown_output = evaluate_permission(unknown_input).unwrap();
assert!(!decision(&unknown_output.workspace.decisions, "Workspace.CreateDoc").allowed);
let mut stale_input = base_input();
stale_input.runtime.stale = true;
let stale_output = evaluate_permission(stale_input).unwrap();
let create_doc = decision(&stale_output.workspace.decisions, "Workspace.CreateDoc");
assert!(!create_doc.allowed);
assert_eq!(create_doc.restrictions[0].restriction_type, "runtime_stale");
}
#[test]
fn legacy_local_workspace_fallback_is_opt_in() {
let mut input = base_input();
input.legacy_compat_mode = true;
input.subject.allow_local = true;
input.workspace = PermissionWorkspaceInputV1 {
local: true,
..Default::default()
};
input.runtime.known = false;
input.runtime.stale = true;
input.workspace_actions = vec!["Workspace.Delete".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.workspace.decisions, "Workspace.Delete").allowed);
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
}
#[test]
fn empty_group_ids_do_not_enable_group_grants() {
let mut input = base_input();
input.docs[0].member_default_role = Some("none".to_string());
input.docs[0].group_grants_enabled = true;
input.docs[0].group_grants = vec![PermissionGroupGrantInputV1 {
group_id: "group".to_string(),
role: "manager".to_string(),
}];
input.docs[0].actions = vec!["Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(!decision(&output.docs[0].decisions, "Doc.Update").allowed);
}
}
@@ -0,0 +1,30 @@
mod actions;
mod candidates;
mod evaluator;
mod types;
use actions::role_matrix_json;
pub use evaluator::evaluate_permission;
use napi::{Error as NapiError, Result, Status};
use napi_derive::napi;
use serde_json::Value;
pub use types::*;
#[napi]
pub fn evaluate_permission_v1(input: Value) -> Result<Value> {
let input = serde_json::from_value::<PermissionEvaluationInputV1>(input)
.map_err(|err| NapiError::new(Status::InvalidArg, err.to_string()))?;
evaluate_permission(input)
.and_then(|output| serde_json::to_value(output).map_err(Into::into))
.map_err(|err| NapiError::new(Status::GenericFailure, err.to_string()))
}
#[napi]
pub fn permission_action_role_matrix_v1() -> Value {
role_matrix_json()
}
#[napi]
pub fn permission_action_role_matrix_v1_json() -> String {
serde_json::to_string_pretty(&role_matrix_json()).unwrap_or_else(|_| "{}".to_string())
}
@@ -0,0 +1,182 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum WorkspaceRole {
External,
Member,
Admin,
Owner,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum DocRole {
None,
External,
Reader,
Commenter,
Editor,
Manager,
Owner,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionSubjectInputV1 {
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub group_ids: Vec<String>,
#[serde(default)]
pub allow_local: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRuntimeInputV1 {
#[serde(default)]
pub known: bool,
#[serde(default)]
pub stale: bool,
#[serde(default)]
pub readonly: bool,
#[serde(default)]
pub readonly_reason: Option<String>,
#[serde(default)]
pub sharing_enabled: Option<bool>,
#[serde(default)]
pub url_preview_enabled: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionWorkspaceInputV1 {
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub member_state: Option<String>,
#[serde(default)]
pub public: bool,
#[serde(default)]
pub sharing_enabled: Option<bool>,
#[serde(default)]
pub url_preview_enabled: Option<bool>,
#[serde(default)]
pub local: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionGroupGrantInputV1 {
pub group_id: String,
pub role: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDocInputV1 {
pub doc_id: String,
#[serde(default)]
pub actions: Vec<String>,
#[serde(default)]
pub explicit_user_role: Option<String>,
#[serde(default)]
pub group_grants: Vec<PermissionGroupGrantInputV1>,
#[serde(default)]
pub group_grants_enabled: bool,
#[serde(default)]
pub member_default_role: Option<String>,
#[serde(default)]
pub public_role: Option<String>,
#[serde(default)]
pub visibility: Option<String>,
#[serde(default)]
pub sharing_enabled: Option<bool>,
#[serde(default)]
pub preview_enabled: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionEvaluationInputV1 {
pub version: u32,
#[serde(default)]
pub legacy_compat_mode: bool,
#[serde(default)]
pub subject: PermissionSubjectInputV1,
#[serde(default)]
pub runtime: PermissionRuntimeInputV1,
#[serde(default)]
pub workspace: PermissionWorkspaceInputV1,
#[serde(default)]
pub workspace_actions: Vec<String>,
#[serde(default)]
pub docs: Vec<PermissionDocInputV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDecisionSourceV1 {
#[serde(rename = "type")]
pub source_type: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDecisionRestrictionV1 {
#[serde(rename = "type")]
pub restriction_type: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDecisionV1 {
pub action: String,
pub allowed: bool,
pub sources: Vec<PermissionDecisionSourceV1>,
pub restrictions: Vec<PermissionDecisionRestrictionV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionWorkspaceEvaluationOutputV1 {
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_owner_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_role: Option<String>,
pub decisions: Vec<PermissionDecisionV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDocEvaluationOutputV1 {
pub doc_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_owner_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_role: Option<String>,
pub decisions: Vec<PermissionDecisionV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionEvaluationOutputV1 {
pub version: u32,
pub workspace: PermissionWorkspaceEvaluationOutputV1,
pub docs: Vec<PermissionDocEvaluationOutputV1>,
}
#[derive(Clone)]
pub(super) struct Candidate {
pub source_type: &'static str,
pub role: String,
pub actions: BTreeSet<String>,
pub owner: bool,
}
+21
View File
@@ -412,11 +412,25 @@ fn build_pinned_client(url: &Url, addrs: &[SocketAddr], timeout: Duration) -> An
.timeout(timeout)
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.tls_backend_preconfigured(webpki_tls_config()?)
.resolve_to_addrs(host, addrs)
.build()
.context("failed to build http client")
}
fn webpki_tls_config() -> AnyResult<rustls::ClientConfig> {
let root_store = rustls::RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
};
Ok(
rustls::ClientConfig::builder_with_provider(rustls::crypto::aws_lc_rs::default_provider().into())
.with_safe_default_protocol_versions()
.context("failed to build tls protocol config")?
.with_root_certificates(root_store)
.with_no_client_auth(),
)
}
fn build_headers(headers: Option<&HashMap<String, String>>) -> AnyResult<HeaderMap> {
let mut out = HeaderMap::new();
let Some(headers) = headers else {
@@ -623,6 +637,13 @@ mod tests {
assert!(!is_blocked_ip("2002:0808:0808::1".parse().unwrap()));
}
#[test]
fn builds_https_client_with_embedded_roots() {
let url = Url::parse("https://example.com/").unwrap();
let addrs = ["93.184.216.34:443".parse().unwrap()];
build_pinned_client(&url, &addrs, Duration::from_secs(1)).unwrap();
}
#[test]
fn inspects_png_dimensions_without_decode() {
let png = base64_simd::STANDARD
@@ -0,0 +1,184 @@
-- CreateTable
CREATE TABLE "entitlements" (
"id" VARCHAR NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" VARCHAR,
"source" TEXT NOT NULL,
"plan" TEXT NOT NULL,
"status" TEXT NOT NULL,
"subject_id" VARCHAR,
"issuer" TEXT,
"quantity" INTEGER,
"signed_payload" BYTEA,
"token_hash" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"issued_at" TIMESTAMPTZ(3),
"starts_at" TIMESTAMPTZ(3),
"expires_at" TIMESTAMPTZ(3),
"validated_at" TIMESTAMPTZ(3),
"grace_until" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id"),
CONSTRAINT "entitlements_target_type_check" CHECK ("target_type" IN ('user', 'workspace', 'instance')),
CONSTRAINT "entitlements_source_check" CHECK ("source" IN ('builtin', 'cloud_subscription', 'selfhost_license', 'admin_grant')),
CONSTRAINT "entitlements_status_check" CHECK ("status" IN ('active', 'grace', 'expired', 'revoked', 'needs_reupload')),
CONSTRAINT "entitlements_quantity_check" CHECK ("quantity" IS NULL OR ("quantity" > 0 AND "quantity" <= 100000))
);
-- CreateTable
CREATE TABLE "effective_user_quota_states" (
"user_id" VARCHAR NOT NULL,
"plan" TEXT NOT NULL,
"source_entitlement_id" VARCHAR,
"blob_limit" BIGINT NOT NULL,
"storage_quota" BIGINT NOT NULL,
"used_storage_quota" BIGINT NOT NULL DEFAULT 0,
"history_period_seconds" INTEGER NOT NULL,
"copilot_action_limit" INTEGER,
"flags" JSONB NOT NULL DEFAULT '{}',
"known" BOOLEAN NOT NULL DEFAULT false,
"stale" BOOLEAN NOT NULL DEFAULT false,
"last_reconciled_at" TIMESTAMPTZ(3),
"stale_after" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "effective_user_quota_states_pkey" PRIMARY KEY ("user_id"),
CONSTRAINT "effective_user_quota_states_blob_limit_check" CHECK ("blob_limit" >= 0),
CONSTRAINT "effective_user_quota_states_storage_quota_check" CHECK ("storage_quota" >= 0),
CONSTRAINT "effective_user_quota_states_used_storage_quota_check" CHECK ("used_storage_quota" >= 0),
CONSTRAINT "effective_user_quota_states_history_period_check" CHECK ("history_period_seconds" >= 0),
CONSTRAINT "effective_user_quota_states_copilot_limit_check" CHECK ("copilot_action_limit" IS NULL OR "copilot_action_limit" >= 0)
);
-- CreateTable
CREATE TABLE "effective_workspace_quota_states" (
"workspace_id" VARCHAR NOT NULL,
"plan" TEXT NOT NULL,
"source_entitlement_id" VARCHAR,
"owner_user_id" VARCHAR,
"uses_owner_quota" BOOLEAN NOT NULL DEFAULT false,
"seat_limit" INTEGER NOT NULL,
"member_count" INTEGER NOT NULL DEFAULT 0,
"overcapacity_member_count" INTEGER NOT NULL DEFAULT 0,
"blob_limit" BIGINT NOT NULL,
"storage_quota" BIGINT NOT NULL,
"used_storage_quota" BIGINT NOT NULL DEFAULT 0,
"history_period_seconds" INTEGER NOT NULL,
"readonly" BOOLEAN NOT NULL DEFAULT false,
"readonly_reasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"flags" JSONB NOT NULL DEFAULT '{}',
"known" BOOLEAN NOT NULL DEFAULT false,
"stale" BOOLEAN NOT NULL DEFAULT false,
"last_reconciled_at" TIMESTAMPTZ(3),
"stale_after" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "effective_workspace_quota_states_pkey" PRIMARY KEY ("workspace_id"),
CONSTRAINT "effective_workspace_quota_states_seat_limit_check" CHECK ("seat_limit" >= 0),
CONSTRAINT "effective_workspace_quota_states_member_count_check" CHECK ("member_count" >= 0),
CONSTRAINT "effective_workspace_quota_states_overcapacity_check" CHECK ("overcapacity_member_count" >= 0),
CONSTRAINT "effective_workspace_quota_states_blob_limit_check" CHECK ("blob_limit" >= 0),
CONSTRAINT "effective_workspace_quota_states_storage_quota_check" CHECK ("storage_quota" >= 0),
CONSTRAINT "effective_workspace_quota_states_used_storage_quota_check" CHECK ("used_storage_quota" >= 0),
CONSTRAINT "effective_workspace_quota_states_history_period_check" CHECK ("history_period_seconds" >= 0),
CONSTRAINT "effective_workspace_quota_states_readonly_reasons_check" CHECK ("readonly_reasons" <@ ARRAY['member_overflow', 'storage_overflow']::TEXT[])
);
-- CreateIndex
CREATE INDEX "entitlements_target_type_target_id_status_idx" ON "entitlements"("target_type", "target_id", "status");
-- CreateIndex
CREATE INDEX "entitlements_status_expires_at_idx" ON "entitlements"("status", "expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "entitlements_active_subject_key" ON "entitlements"("source", "subject_id")
WHERE "subject_id" IS NOT NULL AND "status" IN ('active', 'grace');
-- CreateIndex
CREATE INDEX "effective_user_quota_states_known_stale_idx" ON "effective_user_quota_states"("known", "stale");
-- CreateIndex
CREATE INDEX "effective_user_quota_states_stale_after_idx" ON "effective_user_quota_states"("stale_after");
-- CreateIndex
CREATE INDEX "effective_workspace_quota_states_owner_user_id_idx" ON "effective_workspace_quota_states"("owner_user_id");
-- CreateIndex
CREATE INDEX "effective_workspace_quota_states_known_stale_idx" ON "effective_workspace_quota_states"("known", "stale");
-- CreateIndex
CREATE INDEX "effective_workspace_quota_states_readonly_stale_idx" ON "effective_workspace_quota_states"("readonly", "stale");
-- CreateIndex
CREATE INDEX "effective_workspace_quota_states_stale_after_idx" ON "effective_workspace_quota_states"("stale_after");
-- AddForeignKey
ALTER TABLE "effective_user_quota_states" ADD CONSTRAINT "effective_user_quota_states_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "effective_user_quota_states" ADD CONSTRAINT "effective_user_quota_states_source_entitlement_id_fkey" FOREIGN KEY ("source_entitlement_id") REFERENCES "entitlements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_source_entitlement_id_fkey" FOREIGN KEY ("source_entitlement_id") REFERENCES "entitlements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION "project_legacy_workspace_readonly_feature"()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM "workspace_features"
WHERE "workspace_id" = OLD."workspace_id"
AND "name" = 'quota_exceeded_readonly_workspace_v1';
RETURN OLD;
END IF;
IF NEW."readonly" THEN
UPDATE "workspace_features"
SET "reason" = 'legacy quota state projection trigger',
"activated" = true
WHERE "workspace_id" = NEW."workspace_id"
AND "name" = 'quota_exceeded_readonly_workspace_v1';
IF NOT FOUND THEN
INSERT INTO "workspace_features"(
"workspace_id",
"name",
"type",
"configs",
"reason",
"activated"
)
VALUES (
NEW."workspace_id",
'quota_exceeded_readonly_workspace_v1',
0,
'{}',
'legacy quota state projection trigger',
true
);
END IF;
ELSE
DELETE FROM "workspace_features"
WHERE "workspace_id" = NEW."workspace_id"
AND "name" = 'quota_exceeded_readonly_workspace_v1';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER "project_legacy_workspace_readonly_feature_trigger"
AFTER INSERT OR UPDATE OF "readonly" OR DELETE ON "effective_workspace_quota_states"
FOR EACH ROW
EXECUTE FUNCTION "project_legacy_workspace_readonly_feature"();
+14 -14
View File
@@ -45,20 +45,20 @@
"@node-rs/argon2": "^2.0.2",
"@node-rs/crc32": "^1.10.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.2.0",
"@opentelemetry/exporter-prometheus": "^0.217.0",
"@opentelemetry/exporter-zipkin": "^2.6.0",
"@opentelemetry/core": "^2.7.1",
"@opentelemetry/exporter-prometheus": "^0.218.0",
"@opentelemetry/exporter-zipkin": "^2.7.1",
"@opentelemetry/host-metrics": "^0.38.3",
"@opentelemetry/instrumentation": "^0.215.0",
"@opentelemetry/instrumentation-graphql": "^0.63.0",
"@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-socket.io": "^0.62.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-metrics": "^2.2.0",
"@opentelemetry/sdk-node": "^0.217.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@opentelemetry/instrumentation": "^0.218.0",
"@opentelemetry/instrumentation-graphql": "^0.66.0",
"@opentelemetry/instrumentation-http": "^0.218.0",
"@opentelemetry/instrumentation-ioredis": "^0.66.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.64.0",
"@opentelemetry/instrumentation-socket.io": "^0.65.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-metrics": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.38.0",
"@prisma/client": "^6.6.0",
"@prisma/instrumentation": "^6.7.0",
@@ -74,7 +74,7 @@
"eventemitter2": "^6.4.9",
"exa-js": "^2.4.0",
"express": "^5.0.1",
"fast-xml-parser": "^5.7.2",
"fast-xml-parser": "^5.8.0",
"get-stream": "^9.0.1",
"google-auth-library": "^10.2.0",
"graphql": "^16.13.2",
+227 -8
View File
@@ -28,8 +28,11 @@ model User {
features UserFeature[]
userStripeCustomer UserStripeCustomer?
workspaces WorkspaceUserRole[]
workspaceMembers WorkspaceMember[]
workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee")
createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter")
// Invite others to join the workspace
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
docPermissions WorkspaceDocUserRole[]
connectedAccounts ConnectedAccount[]
calendarAccounts CalendarAccount[]
@@ -37,20 +40,22 @@ model User {
aiSessions AiSession[]
appConfigs AppConfig[]
userSnapshots UserSnapshot[]
createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
createdUpdate Update[] @relation("createdUpdate")
createdHistory SnapshotHistory[] @relation("createdHistory")
createdAiJobs AiJobs[] @relation("createdAiJobs")
createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
createdUpdate Update[] @relation("createdUpdate")
createdHistory SnapshotHistory[] @relation("createdHistory")
createdAiJobs AiJobs[] @relation("createdAiJobs")
// receive notifications
notifications Notification[] @relation("user_notifications")
notifications Notification[] @relation("user_notifications")
settings UserSettings?
comments Comment[]
replies Reply[]
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
AccessToken AccessToken[]
workspaceCalendars WorkspaceCalendar[]
workspaceMemberLastAccesses WorkspaceMemberLastAccess[]
quotaState EffectiveUserQuotaState?
ownedQuotaStates EffectiveWorkspaceQuotaState[]
@@index([email])
@@map("users")
@@ -157,12 +162,226 @@ model Workspace {
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
workspaceDocViewDaily WorkspaceDocViewDaily[]
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
runtimeState WorkspaceRuntimeState?
quotaState EffectiveWorkspaceQuotaState?
accessPolicy WorkspaceAccessPolicy?
projectedMembers WorkspaceMember[]
projectedInvitations WorkspaceInvitation[]
docAccessPolicies DocAccessPolicy[]
docGrants DocGrant[]
@@index([lastCheckEmbeddings])
@@index([createdAt])
@@map("workspaces")
}
model WorkspaceRuntimeState {
workspaceId String @id @map("workspace_id") @db.VarChar
known Boolean @default(false)
readonly Boolean @default(false)
readonlyReasons String[] @default([]) @map("readonly_reasons")
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@map("workspace_runtime_states")
}
model Entitlement {
id String @id @default(uuid()) @db.VarChar
targetType String @map("target_type") @db.Text
targetId String? @map("target_id") @db.VarChar
source String @db.Text
plan String @db.Text
status String @db.Text
subjectId String? @map("subject_id") @db.VarChar
issuer String? @db.Text
quantity Int? @db.Integer
signedPayload Bytes? @map("signed_payload") @db.ByteA
tokenHash String? @map("token_hash") @db.Text
metadata Json @default("{}") @db.JsonB
issuedAt DateTime? @map("issued_at") @db.Timestamptz(3)
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
validatedAt DateTime? @map("validated_at") @db.Timestamptz(3)
graceUntil DateTime? @map("grace_until") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
userQuotaStates EffectiveUserQuotaState[]
workspaceQuotaStates EffectiveWorkspaceQuotaState[]
@@index([targetType, targetId, status])
@@index([status, expiresAt])
@@map("entitlements")
}
model EffectiveUserQuotaState {
userId String @id @map("user_id") @db.VarChar
plan String @db.Text
sourceEntitlementId String? @map("source_entitlement_id") @db.VarChar
blobLimit BigInt @map("blob_limit") @db.BigInt
storageQuota BigInt @map("storage_quota") @db.BigInt
usedStorageQuota BigInt @default(0) @map("used_storage_quota") @db.BigInt
historyPeriodSeconds Int @map("history_period_seconds") @db.Integer
copilotActionLimit Int? @map("copilot_action_limit") @db.Integer
flags Json @default("{}") @db.JsonB
known Boolean @default(false)
stale Boolean @default(false)
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
sourceEntitlement Entitlement? @relation(fields: [sourceEntitlementId], references: [id], onDelete: SetNull)
@@index([known, stale])
@@index([staleAfter])
@@map("effective_user_quota_states")
}
model EffectiveWorkspaceQuotaState {
workspaceId String @id @map("workspace_id") @db.VarChar
plan String @db.Text
sourceEntitlementId String? @map("source_entitlement_id") @db.VarChar
ownerUserId String? @map("owner_user_id") @db.VarChar
usesOwnerQuota Boolean @default(false) @map("uses_owner_quota")
seatLimit Int @map("seat_limit") @db.Integer
memberCount Int @default(0) @map("member_count") @db.Integer
overcapacityMemberCount Int @default(0) @map("overcapacity_member_count") @db.Integer
blobLimit BigInt @map("blob_limit") @db.BigInt
storageQuota BigInt @map("storage_quota") @db.BigInt
usedStorageQuota BigInt @default(0) @map("used_storage_quota") @db.BigInt
historyPeriodSeconds Int @map("history_period_seconds") @db.Integer
readonly Boolean @default(false)
readonlyReasons String[] @default([]) @map("readonly_reasons") @db.Text
flags Json @default("{}") @db.JsonB
known Boolean @default(false)
stale Boolean @default(false)
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
sourceEntitlement Entitlement? @relation(fields: [sourceEntitlementId], references: [id], onDelete: SetNull)
@@index([ownerUserId])
@@index([known, stale])
@@index([readonly, stale])
@@index([staleAfter])
@@map("effective_workspace_quota_states")
}
model WorkspaceMember {
id String @id @default(dbgenerated()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("user_id") @db.VarChar
role String @db.Text
state String @default("active") @db.Text
source String @default("legacy") @db.Text
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([workspaceId, userId, state])
@@index([userId, state])
@@index([workspaceId, role, state])
@@map("workspace_members")
}
model WorkspaceInvitation {
id String @id @default(dbgenerated()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
inviteeUserId String? @map("invitee_user_id") @db.VarChar
normalizedEmail String? @map("normalized_email") @db.VarChar
inviterUserId String? @map("inviter_user_id") @db.VarChar
requestedRole String @default("member") @map("requested_role") @db.Text
status String @db.Text
kind String @default("email") @db.Text
// Partial unique index exists in migration: token_hash WHERE token_hash IS NOT NULL.
tokenHash String? @map("token_hash") @db.Text
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull)
inviter User? @relation("workspace_invitation_inviter", fields: [inviterUserId], references: [id], onDelete: SetNull)
@@unique([workspaceId, inviteeUserId])
@@index([workspaceId, status])
@@index([inviteeUserId, status])
@@index([workspaceId, normalizedEmail, status])
@@map("workspace_invitations")
}
model WorkspaceAccessPolicy {
workspaceId String @id @map("workspace_id") @db.VarChar
visibility String @default("private") @db.Text
sharingEnabled Boolean @default(true) @map("sharing_enabled")
urlPreviewEnabled Boolean @default(false) @map("url_preview_enabled")
memberDefaultDocRole String @default("manager") @map("member_default_doc_role") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([visibility])
@@index([urlPreviewEnabled, sharingEnabled])
@@map("workspace_access_policies")
}
model DocAccessPolicy {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
visibility String @default("private") @db.Text
publicRole String? @map("public_role") @db.Text
memberDefaultRole String? @map("member_default_role") @db.Text
urlPreviewEnabled Boolean @default(false) @map("url_preview_enabled")
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId])
@@index([workspaceId, docId])
@@map("doc_access_policies")
}
model DocGrant {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
principalType String @map("principal_type") @db.Text
principalId String @map("principal_id") @db.VarChar
role String @db.Text
grantedBy String? @map("granted_by") @db.VarChar
legacyWorkspaceId String? @map("legacy_workspace_id") @db.VarChar
legacyDocId String? @map("legacy_doc_id") @db.VarChar
legacyUserId String? @map("legacy_user_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId, principalType, principalId])
// Partial unique index exists in migration for non-null legacy ids.
@@index([principalType, principalId, role])
@@index([workspaceId, docId, role])
@@map("doc_grants")
}
// Table for workspace page meta data
// NOTE:
// We won't make sure every page has a corresponding record in this table.
@@ -5,6 +5,7 @@ import ava, { type ExecutionContext, type TestFn } from 'ava';
import Sinon from 'sinon';
import { Cache, CryptoHelper } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { Models, WorkspaceRole } from '../../models';
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
import { ByokService } from '../../plugins/copilot/byok';
@@ -14,6 +15,11 @@ import {
ByokKeyTestStatus,
ByokProvider,
} from '../../plugins/copilot/byok/types';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
@@ -24,11 +30,18 @@ interface Context {
byok: ByokService;
crypto: CryptoHelper;
cache: Cache;
entitlement: EntitlementService;
}
const test = ava as TestFn<Context>;
const test = ava.serial as TestFn<Context>;
const originalNamespace = globalThis.env.NAMESPACE;
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
test.before(async t => {
Object.assign(globalThis.env, {
NAMESPACE: 'dev',
DEPLOYMENT_TYPE: 'affine',
});
const module = await createTestingModule();
t.context.module = module;
t.context.models = module.get(Models);
@@ -37,6 +50,7 @@ test.before(async t => {
t.context.byok = module.get(ByokService);
t.context.crypto = module.get(CryptoHelper);
t.context.cache = module.get(Cache);
t.context.entitlement = module.get(EntitlementService);
});
test.beforeEach(async t => {
@@ -45,6 +59,10 @@ test.beforeEach(async t => {
test.after.always(async t => {
await t.context.module.close();
Object.assign(globalThis.env, {
NAMESPACE: originalNamespace,
DEPLOYMENT_TYPE: originalDeploymentType,
});
});
async function createUserWorkspace(t: ExecutionContext<Context>) {
@@ -59,6 +77,73 @@ function workspaceHash(workspaceId: string) {
return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12);
}
async function grantUserPlan(
t: ExecutionContext<Context>,
userId: string,
feature: ByokUserPlanFeature = 'pro_plan_v1'
) {
if (feature === 'unlimited_copilot') {
await t.context.entitlement.upsertFromCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
return;
}
await t.context.entitlement.upsertFromCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.Pro,
recurring:
feature === 'lifetime_pro_plan_v1'
? SubscriptionRecurring.Lifetime
: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
}
async function revokeUserPlan(
t: ExecutionContext<Context>,
userId: string,
feature: ByokUserPlanFeature = 'pro_plan_v1'
) {
if (feature === 'unlimited_copilot') {
await t.context.entitlement.revokeCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.AI,
});
return;
}
await t.context.entitlement.revokeCloudSubscription({
targetId: userId,
plan: SubscriptionPlan.Pro,
});
}
async function grantTeamPlan(
t: ExecutionContext<Context>,
workspaceId: string
) {
await t.context.entitlement.upsertFromCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
});
}
async function revokeTeamPlan(
t: ExecutionContext<Context>,
workspaceId: string
) {
await t.context.entitlement.revokeCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
});
}
type ByokMatrixCase = {
name: string;
role: WorkspaceRole;
@@ -110,25 +195,13 @@ async function createByokMatrixWorkspace(
);
}
if (input.team) {
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'test'
);
await grantTeamPlan(t, workspace.id);
}
if (input.ownerPlan) {
await t.context.models.userFeature.add(
owner.id,
input.ownerPlanFeature ?? 'pro_plan_v1',
'test'
);
await grantUserPlan(t, owner.id, input.ownerPlanFeature);
}
if (input.actorPlan && actor.id !== owner.id) {
await t.context.models.userFeature.add(
actor.id,
input.actorPlanFeature ?? 'pro_plan_v1',
'test'
);
await grantUserPlan(t, actor.id, input.actorPlanFeature);
}
return { owner, actor, workspace };
@@ -252,7 +325,7 @@ for (const matrixCase of byokManagementMatrix) {
test('byok service persists encrypted server keys and never returns plaintext', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const primary = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -325,7 +398,7 @@ test('byok service persists encrypted server keys and never returns plaintext',
test('byok service preserves server key fields during partial updates', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -381,7 +454,7 @@ test('byok service preserves server key fields during partial updates', async t
test('local leases are short lived and do not persist keys to server configs', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const before = Date.now();
const lease = await t.context.byok.createLocalLease({
@@ -486,7 +559,7 @@ test('local leases persist normalized custom endpoints', async t => {
).get(() => true);
t.teardown(() => customEndpointSupported.restore());
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const lease = await t.context.byok.createLocalLease({
workspaceId: workspace.id,
@@ -659,13 +732,10 @@ for (const matrixCase of byokProfileAvailabilityMatrix) {
}
if (matrixCase.revokeOwnerPlan) {
await t.context.models.userFeature.remove(owner.id, 'pro_plan_v1');
await revokeUserPlan(t, owner.id);
}
if (matrixCase.revokeTeam) {
await t.context.models.workspaceFeature.remove(
workspace.id,
'team_plan_v1'
);
await revokeTeamPlan(t, workspace.id);
}
if (matrixCase.demoteActor) {
await t.context.models.workspaceUser.set(
@@ -695,7 +765,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const profiles = await t.context.byok.getProfiles({
workspaceId: randomUUID(),
@@ -707,7 +777,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
test('test key failure disables a saved key and success restores it', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -778,7 +848,7 @@ test('test key failure disables a saved key and success restores it', async t =>
test('local key test does not mutate saved server config', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -817,7 +887,7 @@ test('local key test does not mutate saved server config', async t => {
test('Gemini key test sends key in header and returns safe failure message', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
new Response(
@@ -852,7 +922,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
test('FAL key test uses read-only platform API probe endpoint', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
new Response('{}', { status: 200 })
@@ -877,7 +947,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
test('provider test failures do not return raw provider response body', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const cases = [
{
body: 'authorization: Bearer token=a+b%2F==',
@@ -925,7 +995,7 @@ test('provider test failures do not return raw provider response body', async t
test('dispatch failure disables server BYOK key by provider id', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -956,7 +1026,7 @@ test('dispatch failure disables server BYOK key by provider id', async t => {
test('dispatch accounting ignores provider ids from another workspace hash', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const otherWorkspace = await t.context.models.workspace.create(user.id);
const key = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
@@ -996,7 +1066,7 @@ test('dispatch accounting ignores provider ids from another workspace hash', asy
test('effective profiles use local lease before server keys and skip disabled keys', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const serverKey = await t.context.byok.upsertConfig({
workspaceId: workspace.id,
userId: user.id,
@@ -1067,7 +1137,7 @@ test('effective profiles use local lease before server keys and skip disabled ke
test('capability warnings match server Gemini background coverage', async t => {
const { user, workspace } = await createUserWorkspace(t);
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
await grantUserPlan(t, user.id);
const emptySettings = await t.context.byok.getSettings(workspace.id, user.id);
t.deepEqual(
@@ -732,7 +732,7 @@ test('should be able to chat with special image model', async t => {
promptName
);
const messageId = await createCopilotMessage(app, sessionId, 'some-tag', [
`https://example.com/${promptName}.jpg`,
smallestPng,
]);
const ret3 = await chatWithImages(app, sessionId, messageId);
t.is(
@@ -17,6 +17,7 @@ import {
import { ConfigModule } from '../../base/config';
import { AuthService } from '../../core/auth';
import { QuotaModule } from '../../core/quota';
import { QuotaStateService } from '../../core/quota/state';
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
import {
ContextCategories,
@@ -101,6 +102,7 @@ type Context = {
actionBridge: ActionRuntimeBridge;
cronJobs: CopilotCronJobs;
subscription: SubscriptionService;
quotaState: QuotaStateService;
};
const buildTurn = (
@@ -199,6 +201,7 @@ test.before(async t => {
const workspaceEmbedding = module.get(CopilotWorkspaceService);
const cronJobs = module.get(CopilotCronJobs);
const subscription = module.get(SubscriptionService);
const quotaState = module.get(QuotaStateService);
t.context.module = module;
t.context.auth = auth;
@@ -225,6 +228,7 @@ test.before(async t => {
t.context.workspaceEmbedding = workspaceEmbedding;
t.context.cronJobs = cronJobs;
t.context.subscription = subscription;
t.context.quotaState = quotaState;
await module.initTestingDB();
});
@@ -2172,7 +2176,7 @@ test('model selection policy should resolve requested optional models consistent
});
test('capability policy host should gate pro model requests by subscription status', async t => {
const { subscription, module } = t.context;
const { quotaState, subscription, module } = t.context;
const capabilityPolicy = module.get(CapabilityPolicyHost);
const mockStatus = (status?: SubscriptionStatus) => {
@@ -2181,6 +2185,10 @@ test('capability policy host should gate pro model requests by subscription stat
// @ts-expect-error mock
getSubscription: async () => (status ? { status } : null),
}));
Sinon.stub(quotaState, 'reconcileUserQuotaState').resolves({
plan: status === SubscriptionStatus.Active ? 'pro' : 'free',
flags: {},
} as Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>);
};
// payment disabled -> allow requested if in optional; pro not blocked
@@ -25,6 +25,7 @@ import {
type PromptMessage,
type StreamObject,
} from '../../plugins/copilot/providers/types';
import { getVertexGoogleBaseUrl } from '../../plugins/copilot/providers/utils';
import {
buildPromptStructuredResponseFromFields,
buildStructuredResponseContract,
@@ -1823,6 +1824,17 @@ test('GeminiVertexProvider should prefetch bearer token for native config', asyn
t.snapshot(config);
});
test('GeminiVertexProvider should build project scoped Vertex base URL', t => {
t.is(
getVertexGoogleBaseUrl({
project: 'p1',
location: 'us-central1',
googleAuthOptions: {},
}),
'https://us-central1-aiplatform.googleapis.com/v1/projects/p1/locations/us-central1/publishers/google'
);
});
test('GeminiVertexProvider should materialize remote attachments before native text path', async t => {
const cases = [
{
@@ -3,7 +3,7 @@ import test from 'ava';
import { z } from 'zod';
import type { DocReader } from '../../core/doc';
import type { AccessController } from '../../core/permission';
import type { PermissionAccess } from '../../core/permission';
import type { Models } from '../../models';
import {
LlmRequest,
@@ -404,7 +404,7 @@ test('doc_read should return specific sync errors for unavailable docs', async t
user: () => ({
workspace: () => ({ doc: () => ({ can: async () => true }) }),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
for (const testCase of cases) {
let docReaderCalled = false;
@@ -447,7 +447,7 @@ test('document search tools should return sync error for local workspace', async
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -510,7 +510,7 @@ test('doc_semantic_search should return empty array when nothing matches', async
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -542,7 +542,7 @@ test('doc_semantic_search should pass BYOK route context into embedding matches'
docs: async () => [],
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const models = {
workspace: {
@@ -595,7 +595,7 @@ test('blob_read should return explicit error when attachment context is missing'
}),
}),
}),
} as unknown as AccessController;
} as unknown as PermissionAccess;
const blobTool = createBlobReadTool(
buildBlobContentGetter(ac, null).bind(null, {
@@ -137,6 +137,21 @@ function createSuccessfulTranscriptBridge(
};
}
function createCopilotTranscriptionService(...deps: unknown[]) {
return new CopilotTranscriptionService(
deps[0] as never,
deps[1] as never,
deps[2] as never,
deps[3] as never,
deps[4] as never,
deps[5] as never,
(deps[6] ?? {
assertQuotaOrByok: Sinon.stub().resolves(undefined),
}) as never,
(deps[7] ?? { publish: Sinon.stub() }) as never
);
}
test('queryTask hides ready transcript task result until settlement', async t => {
const payload = TranscriptPayloadSchema.parse({
infos: [
@@ -148,7 +163,7 @@ test('queryTask hides ready transcript task result until settlement', async t =>
],
normalizedTranscript: '00:00:05 A: Kickoff',
});
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -181,7 +196,7 @@ test('settleTask unlocks ready transcript task result idempotently', async t =>
status: 'settled',
protectedResult: payload,
});
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -216,7 +231,7 @@ test('settleTask checks copilot quota before unlocking ready task', async t => {
protectedResult: payload,
});
const assertQuotaOrByok = Sinon.stub().rejects(new Error('quota exceeded'));
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -248,7 +263,7 @@ test('settleTask checks copilot quota before unlocking ready task', async t => {
});
test('retryTask rejects ready transcript tasks', async t => {
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -272,7 +287,7 @@ test('retryTask rejects ready transcript tasks', async t => {
});
test('retryTask rejects settled transcript tasks', async t => {
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -306,7 +321,7 @@ test('retryTask reuses failed task and queues a new action attempt', async t =>
summaryJson: null,
providerMeta: { provider: 'gemini', model: 'gemini-2.5-flash' },
});
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -352,7 +367,7 @@ test('retryTask prechecks quota or BYOK before queueing provider work', async t
const payload = TranscriptPayloadSchema.parse({
normalizedTranscript: '00:00:05 A: Kickoff',
});
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -391,7 +406,7 @@ for (const status of ['ready', 'settled']) {
test(`submitTask allows a new task for the same blob after ${status} task`, async t => {
const createdTasks: unknown[] = [];
const queuedJobs: unknown[] = [];
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
@@ -439,7 +454,7 @@ for (const status of ['ready', 'settled']) {
test('submitTask prechecks quota or BYOK before persisting uploads', async t => {
const assertQuotaOrByok = Sinon.stub().rejects(new Error('quota exceeded'));
const resolveTranscriptionModel = Sinon.stub().resolves('gemini-2.5-flash');
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves(null),
@@ -468,7 +483,7 @@ test('submitTask prechecks quota or BYOK before persisting uploads', async t =>
});
test('submitTask rejects unavailable transcript strategy', async t => {
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves(null),
@@ -515,7 +530,7 @@ test('transcriptTask runs native transcript recipe through action bridge when av
const bridgeInputs: unknown[] = [];
const markRunning = Sinon.stub().resolves({ id: 'task-1' });
const complete = Sinon.stub().resolves({ id: 'task-1', status: 'ready' });
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
get: Sinon.stub().resolves({
@@ -586,7 +601,7 @@ test('transcriptTask fails task when native action bridge reports an error event
normalizedTranscript: '00:00:05 A: Kickoff',
});
const complete = Sinon.stub().resolves({ id: 'task-1', status: 'failed' });
const service = new CopilotTranscriptionService(
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
get: Sinon.stub().resolves({
@@ -57,6 +57,21 @@ function getSnapshot(timestamp: number = Date.now()): DocRecord {
};
}
test('history max age converts quota seconds to milliseconds', async t => {
Sinon.restore();
const options = m.get(DocStorageOptions);
// @ts-expect-error private service boundary is asserted here
Sinon.stub(options.quota, 'getWorkspaceQuota').resolves({
name: 'Pro',
blobLimit: 1,
storageQuota: 1,
historyPeriod: 30,
memberLimit: 1,
});
t.is(await options.historyMaxAge('1'), 30_000);
});
test('should create doc history if never created before', async t => {
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves(null);
@@ -273,16 +273,64 @@ e2e('should update comment work', async t => {
t.truthy(result.updateComment);
});
e2e('should update comment failed by another user', async t => {
e2e('should update comment work by doc Editor', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Editor,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: workspace.id,
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(member);
const result = await app.gql({
query: updateCommentMutation,
variables: {
input: {
id: createResult.createComment.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test update' }],
},
},
},
});
t.truthy(result.updateComment);
});
e2e('should update comment failed without update permission', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Reader,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
@@ -1145,15 +1193,79 @@ e2e('should update reply work when user is reply owner', async t => {
t.truthy(result.updateReply);
});
e2e('should update reply failed when user is not reply owner', async t => {
e2e('should update reply work by doc Editor', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Editor,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: workspace.id,
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
const createReplyResult = await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(member);
const result = await app.gql({
query: updateReplyMutation,
variables: {
input: {
id: createReplyResult.createReply.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test update' }],
},
},
},
});
t.truthy(result.updateReply);
});
e2e('should update reply failed without update permission', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Reader,
});
await app.login(owner);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
@@ -28,37 +28,43 @@ e2e('should render doc share page with apple-itunes-app meta tag', async t => {
);
});
e2e(
e2e.serial(
'should render doc share page without apple-itunes-app meta tag when selfhosted',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error override
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
await using app = await createApp();
try {
await using app = await createApp();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
t.notRegex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
t.notRegex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
@@ -69,6 +69,64 @@ e2e('should get recently updated docs', async t => {
t.is(recentlyUpdatedDocs.edges[2].node.title, doc1.title);
});
e2e('should filter recently updated docs by doc read permission', async t => {
const owner = await app.signup();
const member = await app.createUser();
await app.login(member);
await app.switchUser(owner);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const privateSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: privateSnapshot.id,
title: 'private-doc',
defaultRole: DocRole.None,
});
const publicSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const publicDoc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: publicSnapshot.id,
title: 'public-doc',
defaultRole: DocRole.None,
public: true,
});
await app.switchUser(member);
const {
workspace: { recentlyUpdatedDocs },
} = await app.gql({
query: getRecentlyUpdatedDocsQuery,
variables: {
workspaceId: workspace.id,
pagination: {
first: 10,
},
},
});
t.is(recentlyUpdatedDocs.totalCount, 1);
t.deepEqual(
recentlyUpdatedDocs.edges.map(edge => edge.node.id),
[publicDoc.docId]
);
});
e2e(
'should get doc with public attribute when doc snapshot not exists',
async t => {
@@ -5,7 +5,6 @@ import {
listNotificationsQuery,
MentionNotificationBodyType,
mentionUserMutation,
notificationCountQuery,
NotificationObjectType,
NotificationType,
readAllNotificationsMutation,
@@ -13,6 +12,7 @@ import {
} from '@affine/graphql';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
async function init() {
@@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => {
},
});
}
const count = await app.gql({
query: notificationCountQuery,
});
t.is(count.currentUser!.notificationCount, 0);
const socket = await createRealtimeClient(app, member);
t.teardown(() => socket.disconnect());
const count = await realtimeRequest(socket, 'notification.count.get', {});
t.is(count.count, 0);
// read again should work
for (const notification of notifications) {
@@ -0,0 +1,92 @@
import type {
RealtimeAck,
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
} from '@affine/realtime';
import { io, type Socket as SocketIOClient } from 'socket.io-client';
import type { Response } from 'supertest';
import type { MockedUser } from '../mocks';
import type { TestingApp } from './create-app';
const REALTIME_CLIENT_VERSION = '0.26.0';
const WS_TIMEOUT_MS = 5_000;
function cookieHeader(res: Response) {
return (res.get('Set-Cookie') ?? [])
.map(cookie => cookie.split(';')[0])
.join('; ');
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
) {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function waitForConnect(socket: SocketIOClient) {
if (socket.connected) {
return;
}
await withTimeout(
new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
}),
WS_TIMEOUT_MS,
'realtime socket connect'
);
}
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
const login = await app.login(user);
const socket = io(app.url, {
transports: ['websocket'],
reconnection: false,
forceNew: true,
extraHeaders: {
cookie: cookieHeader(login),
},
});
await waitForConnect(socket);
return socket;
}
export async function realtimeRequest<Op extends RealtimeRequestName>(
socket: SocketIOClient,
op: Op,
input: RealtimeRequestInputOf<Op>
): Promise<RealtimeRequestOutputOf<Op>> {
const ack = await withTimeout(
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
socket.emit(
'realtime:request',
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
);
}),
WS_TIMEOUT_MS,
`realtime request ${op}`
);
if ('error' in ack) {
throw new Error(`${ack.error.name}: ${ack.error.message}`);
}
return ack.data;
}
@@ -15,9 +15,18 @@ import {
R2StorageProvider,
} from '../../../base/storage/providers/r2';
import { SIGNED_URL_EXPIRED } from '../../../base/storage/providers/utils';
import { WorkspaceBlobStorage } from '../../../core/storage';
import { EntitlementService } from '../../../core/entitlement';
import {
CommentAttachmentStorage,
WorkspaceBlobStorage,
} from '../../../core/storage';
import { MULTIPART_THRESHOLD } from '../../../core/storage/constants';
import { R2UploadController } from '../../../core/storage/r2-proxy';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { app, e2e, Mockers } from '../test';
class MockR2Provider extends R2StorageProvider {
@@ -160,6 +169,8 @@ async function setBlobStorage(storage: StorageProviderConfig) {
configFactory.override({ storages: { blob: { storage } } });
const blobStorage = app.get(WorkspaceBlobStorage);
await blobStorage.onConfigInit();
const commentAttachmentStorage = app.get(CommentAttachmentStorage);
await commentAttachmentStorage.onConfigInit();
const controller = app.get(R2UploadController);
// reset cached provider in controller
(controller as any).provider = null;
@@ -245,7 +256,13 @@ async function getBlobUploadPartUrl(
}
async function setupWorkspace() {
const owner = await app.signup({ feature: 'pro_plan_v1' });
const owner = await app.signup();
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
});
const workspace = await app.create(Mockers.Workspace, { owner });
return { owner, workspace };
}
@@ -435,7 +452,13 @@ e2e(
e2e(
'should still fallback to graphql when provider does not support presign',
async t => {
await setBlobStorage(defaultBlobStorage);
await setBlobStorage({
provider: 'fs',
bucket: 'test-fallback-bucket',
config: {
path: '/tmp/affine-r2-proxy-test',
},
});
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('graph');
@@ -1,6 +1,11 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import {
Config,
ConfigFactory,
type StorageProviderConfig,
} from '../../../base';
import { CommentAttachmentStorage } from '../../../core/storage';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
@@ -21,6 +26,11 @@ e2e.afterEach.always(() => {
mock.reset();
});
async function useCommentAttachmentBlobStorage(storage: StorageProviderConfig) {
app.get(ConfigFactory).override({ storages: { blob: { storage } } });
await app.get(CommentAttachmentStorage).onConfigInit();
}
// #region comment attachment
e2e(
@@ -61,35 +71,50 @@ e2e(
}
);
e2e('should get comment attachment body', async t => {
e2e.serial('should get comment attachment body', async t => {
const defaultBlobStorage = structuredClone(
app.get(Config).storages.blob.storage
);
await useCommentAttachmentBlobStorage({
provider: 'fs',
bucket: 'test-comment-attachment',
config: {
path: '/tmp/affine-test-comment-attachment',
},
});
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test'),
owner.id
);
try {
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test'),
owner.id
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
} finally {
await useCommentAttachmentBlobStorage(defaultBlobStorage);
}
});
e2e('should get comment attachment redirect url', async t => {
@@ -1,28 +1,36 @@
import { randomUUID } from 'node:crypto';
import {
acceptInviteByInviteIdMutation,
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
deleteBlobMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailsMutation,
leaveWorkspaceMutation,
releaseDeletedBlobsMutation,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
} from '@prisma/client';
import { Models } from '../../../models';
import { FeatureConfigs } from '../../../models/common/feature';
import { EntitlementService } from '../../../core/entitlement';
import { WorkspacePolicyService } from '../../../core/permission';
import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
const TWO_BILLION_BYTES = 2_000_000_000;
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
@@ -35,6 +43,23 @@ async function createWorkspace() {
};
}
async function grantTeamPlan(workspaceId: string, quantity: number) {
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity,
});
}
async function revokeTeamPlan(workspaceId: string) {
await app.get(EntitlementService).revokeCloudSubscription({
targetId: workspaceId,
plan: SubscriptionPlan.Team,
});
}
e2e('should invite a user', async t => {
const { owner, workspace } = await createWorkspace();
const u2 = await app.create(Mockers.User);
@@ -91,19 +116,16 @@ e2e('should invite a user', async t => {
e2e('should re-check seat when accepting an email invitation', async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 4,
});
await grantTeamPlan(workspace.id, 12);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await Promise.all(
Array.from({ length: 10 }).map(async () => {
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
})
);
await app.login(owner);
const invite = await app.gql({
@@ -116,10 +138,10 @@ e2e('should re-check seat when accepting an email invitation', async t => {
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 4,
quantity: 12,
});
await app.models.workspaceFeature.remove(workspace.id, 'team_plan_v1');
await revokeTeamPlan(workspace.id);
await app.login(member);
await t.throwsAsync(
@@ -147,24 +169,6 @@ e2e.serial(
async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
const freeStorageQuota = FeatureConfigs.free_plan_v1.configs.storageQuota;
const lifetimeStorageQuota =
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota;
FeatureConfigs.free_plan_v1.configs.storageQuota = 1;
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = 2;
t.teardown(() => {
FeatureConfigs.free_plan_v1.configs.storageQuota = freeStorageQuota;
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota =
lifetimeStorageQuota;
});
await app.models.userFeature.switchQuota(
owner.id,
'lifetime_pro_plan_v1',
'test setup'
);
await app.login(owner);
const invite = await app.gql({
query: inviteByEmailsMutation,
@@ -174,26 +178,26 @@ e2e.serial(
},
});
await app.models.blob.upsert({
workspaceId: workspace.id,
key: 'overflow-blob',
mime: 'application/octet-stream',
size: 2,
status: 'completed',
uploadId: null,
});
await app.eventBus.emitAsync('user.subscription.canceled', {
userId: owner.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
});
const overflowBlobKeys = Array.from(
{ length: 6 },
(_, index) => `overflow-blob-${index}`
);
await Promise.all(
overflowBlobKeys.map(key =>
app.models.blob.upsert({
workspaceId: workspace.id,
key,
mime: 'application/octet-stream',
size: TWO_BILLION_BYTES,
status: 'completed',
uploadId: null,
})
)
);
t.true(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
await app.login(member);
@@ -216,26 +220,13 @@ e2e.serial(
t.is(pendingInvite.status, WorkspaceMemberStatus.Pending);
await app.login(owner);
await app.gql({
query: deleteBlobMutation,
variables: {
workspaceId: workspace.id,
key: 'overflow-blob',
permanently: false,
},
});
await app.gql({
query: releaseDeletedBlobsMutation,
variables: {
workspaceId: workspace.id,
},
});
for (const key of overflowBlobKeys) {
await app.models.blob.delete(workspace.id, key, true);
}
t.false(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
await app.login(member);
@@ -393,39 +384,31 @@ e2e('should support pagination for member', async t => {
userId: u2.id,
});
await app.login(owner);
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 2,
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 2);
t.is(result.memberCount, 3);
t.is(result.members.length, 2);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 2,
take: 2,
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 2,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 3,
take: 2,
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 3,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 0);
t.is(result.memberCount, 3);
t.is(result.members.length, 0);
});
e2e('should limit member count correctly', async t => {
@@ -441,17 +424,15 @@ e2e('should limit member count correctly', async t => {
})
);
await app.login(owner);
const result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 10,
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
const result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 10,
});
t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10);
t.is(result.memberCount, 11);
t.is(result.members.length, 10);
});
e2e('should get invite link info with status', async t => {
@@ -596,10 +577,7 @@ e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createWorkspace();
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 3,
});
await grantTeamPlan(workspace.id, 3);
await app.login(owner);
const { createInviteLink } = await app.gql({
@@ -639,10 +617,7 @@ e2e(
name: faker.internet.displayName({ firstName: 'Lucy' }),
});
const user2 = await app.create(Mockers.User, {
email: faker.internet.email({
firstName: 'Jeanne',
lastName: 'Doe',
}),
email: `jeanne_doe.${randomUUID()}@affine.pro`,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
@@ -653,38 +628,54 @@ e2e(
userId: user2.id,
});
await app.login(owner);
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'lucy',
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'lucy',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].name, user1.name);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'LUCY',
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'LUCY',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].name, user1.name);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'jeanne_doe',
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'jeanne_doe',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].email, user2.email);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].email, user2.email);
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
const pendingUser = await app.create(Mockers.User, {
email: pendingEmail,
});
await app
.get(Models)
.workspaceUser.set(
workspace.id,
pendingUser.id,
ModelWorkspaceRole.Collaborator,
{
status: PrismaWorkspaceMemberStatus.Pending,
source: WorkspaceMemberSource.Email,
}
);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'pending_search',
});
t.is(result.memberCount, 4);
t.is(result.members.length, 1);
t.is(result.members[0].email, pendingEmail);
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
}
);
@@ -6,6 +6,7 @@ import {
revokePublicPageMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { PrismaClient } from '@prisma/client';
import { QuotaService } from '../../../core/quota/service';
import { WorkspaceRole } from '../../../models';
@@ -98,7 +99,31 @@ const revokeMember = async (workspaceId: string, userId: string) => {
return revokeMember;
};
e2e('should set new invited users to AllocatingSeat', async t => {
const cancelTeamWorkspace = async (workspaceId: string) => {
const db = app.get(PrismaClient);
await db.entitlement.updateMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
plan: 'team',
},
data: { status: 'revoked' },
});
await db.subscription.updateMany({
where: {
targetId: workspaceId,
plan: SubscriptionPlan.Team,
},
data: { status: 'canceled' },
});
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
};
e2e('should set new invited users to waiting-seat status', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
@@ -117,7 +142,7 @@ e2e('should set new invited users to AllocatingSeat', async t => {
const invitationInfo = await getInvitationInfo(
result.inviteMembers[0].inviteId!
);
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
t.is(invitationInfo.status, WorkspaceMemberStatus.NeedMoreSeat);
});
e2e('should allocate seats', async t => {
@@ -151,11 +176,11 @@ e2e('should allocate seats', async t => {
});
t.is(
members.find(m => m.user.id === u1.id)?.status,
members.find(m => m.user?.id === u1.id)?.status,
WorkspaceMemberStatus.Pending
);
t.is(
members.find(m => m.user.id === u2.id)?.status,
members.find(m => m.user?.id === u2.id)?.status,
WorkspaceMemberStatus.Accepted
);
@@ -201,11 +226,11 @@ e2e('should set all rests to NeedMoreSeat', async t => {
});
t.is(
members.find(m => m.user.id === u2.id)?.status,
members.find(m => m.user?.id === u2.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
t.is(
members.find(m => m.user.id === u3.id)?.status,
members.find(m => m.user?.id === u3.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
});
@@ -237,11 +262,7 @@ e2e(
status: WorkspaceMemberStatus.UnderReview,
});
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 20,
@@ -265,11 +286,7 @@ e2e(
async t => {
const { workspace, owner, admin } = await createTeamWorkspace();
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.false(
@@ -306,11 +323,7 @@ e2e(
await app.login(owner);
await publishDoc(workspace.id, 'published-doc');
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.true(
@@ -325,7 +338,7 @@ e2e(
);
await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc'));
await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc'));
await t.throwsAsync(revokePublicDoc(workspace.id, 'published-doc'));
const quota = await app
.get(QuotaService)
@@ -27,6 +27,16 @@ export class MockTeamWorkspace extends Mocker<
quantity,
},
});
await this.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: id,
source: 'cloud_subscription',
plan: 'team',
status: 'active',
quantity,
},
});
await this.db.workspaceFeature.create({
data: {
@@ -45,6 +45,55 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
: undefined,
},
});
const runtimeStateColumns = await this.db.$queryRaw<
Array<{ exists: boolean }>
>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`;
if (runtimeStateColumns[0]?.exists) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
known = true,
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
last_reconciled_at = now(),
stale_after = NULL,
updated_at = now()
`;
} else {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
stale_at = NULL,
updated_at = now()
`;
}
// create a rootDoc snapshot
if (snapshot) {
@@ -73,6 +73,24 @@ test('should set doc user role', async t => {
t.is(role?.type, DocRole.Manager);
});
test('should batch update existing doc user roles', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Reader);
const count = await models.docUser.batchSetUserRoles(
workspace.id,
docId,
[user.id],
DocRole.Editor
);
const role = await models.docUser.get(workspace.id, docId, user.id);
t.is(count, 1);
t.is(role?.type, DocRole.Editor);
});
test('should not allow setting doc owner through setDocUserRole', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
@@ -96,6 +114,23 @@ test('should delete doc user role', async t => {
t.is(role, null);
});
test('should delete doc grants by user id', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
await models.docUser.deleteByUserId(user.id);
t.is(await models.docUser.get(workspace.id, docId, user.id), null);
t.is(
await db.docGrant.count({
where: { principalType: 'user', principalId: user.id },
}),
0
);
});
test('should paginate doc user roles', async t => {
const workspace = await create();
const docId = 'fake-doc-id';
@@ -1,12 +1,16 @@
import { User } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
import { AvailableUserFeatureConfig } from '../../core/features/types';
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
import { Feature } from '../../models/common/feature';
import { createTestingModule, TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: UserFeatureModel;
resolver: AdminFeatureManagementResolver;
u1: User;
}
@@ -16,6 +20,7 @@ test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(UserFeatureModel);
t.context.resolver = module.get(AdminFeatureManagementResolver);
t.context.module = module;
});
@@ -31,6 +36,21 @@ test.after(async t => {
await t.context.module.close();
});
test('configurable user features exclude commercial projection features', t => {
const config = new AvailableUserFeatureConfig();
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
});
test('admin feature resolver rejects commercial projection features', async t => {
await t.throwsAsync(
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
{ message: /not configurable/ }
);
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
});
test('should get null if user feature not found', async t => {
const { model, u1 } = t.context;
const userFeature = await model.get(u1.id, 'ai_early_access');
@@ -39,12 +59,14 @@ test('should get null if user feature not found', async t => {
test('should get user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userFeature = await model.get(u1.id, 'free_plan_v1');
t.is(userFeature?.name, 'free_plan_v1');
});
test('should get user quota', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userQuota = await model.getQuota(u1.id);
t.snapshot(userQuota?.configs, 'free plan');
});
@@ -52,6 +74,7 @@ test('should get user quota', async t => {
test('should list user features', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.like(await model.list(u1.id), ['free_plan_v1']);
});
@@ -68,6 +91,7 @@ test('should list user features by type', async t => {
test('should directly test user feature existence', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.true(await model.has(u1.id, 'free_plan_v1'));
t.false(await model.has(u1.id, 'ai_early_access'));
});
@@ -112,6 +136,7 @@ test('should switch user quota', async t => {
test('should not switch user quota if the new quota is the same as the current one', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
// @ts-expect-error private
@@ -135,6 +160,7 @@ test('should use pro plan as free for selfhost instance', async t => {
registered: true,
});
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
const quota = await models.userFeature.getQuota(u1.id);
t.snapshot(
quota?.configs,
@@ -1,6 +1,7 @@
import { Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
import {
FeatureType,
UserModel,
@@ -12,6 +13,7 @@ import { createTestingModule, type TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: WorkspaceFeatureModel;
resolver: AdminWorkspaceResolver;
ws: Workspace;
}
@@ -21,6 +23,7 @@ test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(WorkspaceFeatureModel);
t.context.resolver = module.get(AdminWorkspaceResolver);
t.context.module = module;
});
@@ -44,6 +47,17 @@ test('should get null if workspace feature not found', async t => {
t.is(userFeature, null);
});
test('admin workspace update changes workspace flags', async t => {
await t.context.resolver.adminUpdateWorkspace({
id: t.context.ws.id,
name: 'updated',
});
t.is(
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
'updated'
);
});
test('should directly test workspace feature existence', async t => {
const { model, ws } = t.context;
@@ -0,0 +1,594 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
import {
DocRole,
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
PermissionProjectionModel,
permissionProjectionTriggerErrorCategory,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../models';
import { createModule } from '../create-module';
import { Mockers } from '../mocks';
const module = await createModule({});
const db = module.get(PrismaClient);
test.after.always(async () => {
await module.close();
});
class TestPermissionProjectionModel extends PermissionProjectionModel {
constructor(private readonly fakeDb: unknown) {
super();
}
protected override get db() {
return this.fakeDb as never;
}
}
let appliedPermissionProjectionTriggerFunctionUpdates = false;
async function applyPermissionProjectionTriggerFunctionUpdates() {
if (appliedPermissionProjectionTriggerFunctionUpdates) {
return;
}
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
for (const name of [
'affine_permission_project_new_workspace_member',
'affine_permission_project_new_workspace_invitation',
'affine_permission_project_new_doc_access_policy',
'affine_permission_project_new_doc_grant',
]) {
const sql = migration.match(
new RegExp(
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
)
)?.[0];
if (!sql) {
throw new Error(`Missing migration function ${name}`);
}
await db.$executeRawUnsafe(sql);
}
appliedPermissionProjectionTriggerFunctionUpdates = true;
}
async function hasCurrentWorkspaceInvitationColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name IN ('requested_role', 'status', 'kind')
`;
return rows.length === 3;
}
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
const queryResults = [
[{ count: 1n }],
[{ count: 2n }],
[{ count: 3n }],
[{ count: 4n }],
[{ count: 5n }],
[{ count: 6n }],
[{ count: 7n }],
[{ count: 8n }],
[{ count: 9n }],
[{ count: 10n }],
[
{ category: 'legacy_doc_external_row', count: 11n },
{ category: 'doc_default_owner', count: 12n },
],
];
const model = new TestPermissionProjectionModel({
$queryRaw: async () => queryResults.shift(),
});
t.deepEqual(await model.checkLegacyProjection(), {
oldWorkspacePolicyMismatch: 1,
oldAcceptedMemberMismatch: 2,
extraProjectedMember: 3,
oldInvitationMismatch: 4,
extraProjectedInvitation: 5,
oldDocGrantMismatch: 6,
extraProjectedDocGrant: 7,
oldDocPolicyMismatch: 8,
extraProjectedDocPolicy: 9,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict: 10,
oldNewDecisionMismatch: 0,
invalidLegacyRows: {
legacy_doc_external_row: 11,
doc_default_owner: 12,
},
});
});
test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => {
const executed: unknown[] = [];
let transactionOptions: unknown;
const model = new TestPermissionProjectionModel({
$transaction: async (
callback: (tx: unknown) => Promise<void>,
options: unknown
) => {
transactionOptions = options;
await callback({
$executeRaw: async (query: unknown) => {
executed.push(query);
},
});
},
});
await model.backfillLegacyProjection();
t.is(executed.length, 11);
t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 });
t.regex(String(executed[0]), /affine\.permission_sync_origin/);
});
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
]);
});
test('permission projection migration uses non-recursive origin guard', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
const guardBody = migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
)?.[0];
t.truthy(guardBody);
t.true(
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
);
t.false(
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
);
t.truthy(
migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
)
);
});
test('permission projection trigger maps legacy workspace permission rows', async t => {
const workspace = await module.create(Mockers.Workspace);
const [admin, pending] = await module.create(Mockers.User, 2);
await db.workspaceUserRole.createMany({
data: [
{
workspaceId: workspace.id,
userId: admin.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
{
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
],
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: admin.id,
state: 'active',
},
});
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
where: {
workspaceId_inviteeUserId: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
},
},
});
t.is(member.role, 'admin');
t.is(invitation.requestedRole, 'member');
t.is(invitation.status, 'pending');
});
test('permission projection trigger maps legacy doc policy rows', async t => {
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'public-doc',
public: true,
defaultRole: DocRole.Reader,
},
});
const policy = await db.docAccessPolicy.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'public-doc',
},
},
});
t.is(policy.visibility, 'public');
t.is(policy.publicRole, 'external');
t.is(policy.memberDefaultRole, 'reader');
});
async function hasDocGrantLegacyProjectionColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'doc_grants'
AND column_name IN (
'legacy_workspace_id',
'legacy_doc_id',
'legacy_user_id'
)
`;
return rows.length === 3;
}
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
if (!(await hasDocGrantLegacyProjectionColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates doc_grants legacy columns'
);
return;
}
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceDocUserRole.createMany({
data: [
{
workspaceId: workspace.id,
docId: 'valid-grant',
userId: user.id,
type: DocRole.Reader,
},
{
workspaceId: workspace.id,
docId: 'dirty-external',
userId: user.id,
type: DocRole.External,
},
{
workspaceId: workspace.id,
docId: 'dirty-none',
userId: user.id,
type: DocRole.None,
},
],
});
const grants = await db.docGrant.findMany({
where: {
workspaceId: workspace.id,
principalId: user.id,
},
orderBy: {
docId: 'asc',
},
});
t.deepEqual(
grants.map(grant => [grant.docId, grant.role]),
[['valid-grant', 'reader']]
);
});
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const member = await db.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId: user.id,
role: 'member',
state: 'active',
},
});
t.truthy(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
);
await db.workspaceMember.update({
where: { id: member.id },
data: { state: 'suspended' },
});
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
if (!(await hasCurrentWorkspaceInvitationColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates workspace invitation projection columns'
);
return;
}
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const [invitation] = await db.$queryRaw<{ id: string }[]>`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
requested_role,
status,
kind
)
VALUES (
${workspace.id},
${user.id},
'member',
'pending',
'email'
)
RETURNING id
`;
t.is(
(
await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
).status,
'Pending'
);
await db.$executeRaw`
UPDATE workspace_invitations
SET status = 'declined'
WHERE id = ${invitation.id}
`;
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'metadata-doc',
public: true,
defaultRole: DocRole.Reader,
mode: 1,
blocked: true,
title: 'Title',
summary: 'Summary',
publishedAt: new Date('2026-01-01T00:00:00Z'),
},
});
await db.docAccessPolicy.delete({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
const doc = await db.workspaceDoc.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
t.is(doc.public, false);
t.is(doc.defaultRole, DocRole.Manager);
t.is(doc.publishedAt, null);
t.is(doc.mode, 1);
t.is(doc.blocked, true);
t.is(doc.title, 'Title');
t.is(doc.summary, 'Summary');
});
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'user',
principalId: user.id,
role: 'reader',
},
});
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
role: 'manager',
},
});
await db.docGrant.delete({
where: {
workspaceId_docId_principalType_principalId: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
},
},
});
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
where: {
workspaceId_docId_userId: {
workspaceId: workspace.id,
docId: 'group-doc',
userId: user.id,
},
},
});
t.is(legacyGrant.type, DocRole.Reader);
});
test('PermissionProjectionModel parses trigger error metric category', t => {
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:owner_conflict:duplicate owner')
),
'owner_conflict'
);
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:unexpected:nope')
),
'unknown'
);
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
});
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
const checker = new PermissionProjectionChecker(
{
workspace: {
findMany: async () => [],
},
$queryRaw: async () => [
{
category: 'active_member_doc',
workspaceId: 'w1',
docId: 'doc1',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'explicit_doc_grant',
workspaceId: 'w1',
docId: 'doc2',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'workspace_invitation',
workspaceId: 'w1',
docId: null,
userId: 'u2',
workspaceActions: ['Workspace.Read'],
docActions: null,
},
],
} as never,
{
permissionProjection: {
checkLegacyProjection: async () => ({}),
},
} as never,
{
load: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: 'legacy' },
docs: input.docs
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
: [],
}),
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: input.docs ? 'legacy' : 'projection' },
docs: input.docs
? [
{
docId: input.docs[0].docId,
marker:
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
},
]
: [],
}),
} as never,
{
evaluate: (input: unknown) => input,
} as never
);
t.deepEqual(await checker.checkLegacyProjection(), {
oldNewDecisionMismatch: 2,
});
});
@@ -151,6 +151,22 @@ test('should not get inactive workspace role', async t => {
t.is(role, null);
});
test('should not activate a missing workspace invitation', async t => {
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await t.throwsAsync(
models.workspaceUser.setStatus(
workspace.id,
user.id,
WorkspaceMemberStatus.Accepted
),
{ message: 'Cannot activate a missing workspace invitation.' }
);
t.is(await models.workspaceUser.get(workspace.id, user.id), null);
});
test('should update user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
@@ -215,6 +231,114 @@ test('should delete workspace user role', async t => {
t.is(role, null);
});
test('should delete legacy-only external workspace user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
await models.workspaceUser.delete(workspace.id, u1.id);
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
});
test('should convert existing workspace user role to legacy-only external role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role?.type, WorkspaceRole.External);
t.is(
await db.workspaceMember.count({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
}),
0
);
});
test('should backfill legacy permission id for new workspace member writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
});
t.is(member.legacyPermissionId, legacyRole.id);
});
test('should backfill legacy permission id for new workspace invitation writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Pending,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const invitation = await db.workspaceInvitation.findFirstOrThrow({
where: {
workspaceId: workspace.id,
inviteeUserId: u1.id,
},
});
t.is(invitation.legacyPermissionId, legacyRole.id);
});
test('should get user workspace roles with filter', async t => {
const ws1 = await module.create(Mockers.Workspace);
const ws2 = await module.create(Mockers.Workspace);
@@ -0,0 +1,50 @@
import test from 'ava';
import { resolveEntitlementV1 } from '../native';
test('native entitlement wrapper maps schema errors to invalid argument', t => {
const error = t.throws(() =>
resolveEntitlementV1({
deploymentType: 'local',
targetType: 'workspace',
now: '2026-05-14T00:00:00Z',
})
);
t.is((error as Error & { code?: string })?.code, 'InvalidArg');
});
test('native entitlement wrapper maps unsafe JS quantity to invalid argument', t => {
const base = {
deploymentType: 'cloud',
targetType: 'workspace',
plan: 'team',
now: '2026-05-14T00:00:00Z',
} as const;
for (const quantity of [4294967297, 1.5, 100001]) {
const error = t.throws(() => resolveEntitlementV1({ ...base, quantity }));
t.is(
(error as Error & { code?: string })?.code,
'InvalidArg',
String(quantity)
);
}
});
test('native entitlement wrapper does not trust forged signed payload buffers', t => {
const resolved = resolveEntitlementV1({
deploymentType: 'selfhosted',
targetType: 'workspace',
targetId: 'workspace-id',
signedPayload: Buffer.from('not-a-valid-license'),
publicKey: 'not-a-valid-public-key',
licenseAesKey: 'not-a-valid-aes-key',
now: '2026-05-14T00:00:00Z',
});
t.false(resolved.valid);
t.is(resolved.status, 'needs_reupload');
t.is(resolved.plan, 'selfhost_free');
});
@@ -0,0 +1,204 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CryptoHelper, EventBus } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { WorkspacePolicyService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceService } from '../../core/workspaces';
import { Models } from '../../models';
import { LicenseService } from '../../plugins/license/service';
import { PaymentEventHandlers } from '../../plugins/payment/event';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '../../plugins/payment/types';
type Context = Record<string, never>;
const test = ava as TestFn<Context>;
test('workspace subscription activation only sends upgrade notification', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
let reconciled = false;
const handler = new PaymentEventHandlers(
{
isTeamWorkspace: async () => true,
sendTeamWorkspaceUpgradedEmail: async () => {},
} as unknown as WorkspaceService,
{
reconcileWorkspaceQuotaState: async () => {
reconciled = true;
},
} as unknown as WorkspacePolicyService,
{
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 7 }),
} as unknown as QuotaStateService,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus
);
await handler.onWorkspaceSubscriptionUpdated({
workspaceId: 'ws',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
quantity: 999,
});
t.deepEqual(events, []);
t.false(reconciled);
});
test('workspace entitlement change allocates seats from effective quota state', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const handler = new PaymentEventHandlers(
{} as unknown as WorkspaceService,
{} as unknown as WorkspacePolicyService,
{
reconcileWorkspaceQuotaState: async () => ({
plan: 'team',
seatLimit: 7,
}),
} as unknown as QuotaStateService,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus
);
await handler.onEntitlementChanged({
targetType: 'workspace',
targetId: 'ws',
});
t.deepEqual(events, [
{
name: 'workspace.members.allocateSeats',
payload: { workspaceId: 'ws', quantity: 7 },
},
]);
});
test('onetime selfhost license seat allocation ignores projected license quantity', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => ({
key: 'license-key',
workspaceId: 'ws',
quantity: 999,
recurring: SubscriptionRecurring.Yearly,
variant: SubscriptionVariant.Onetime,
}),
},
} as unknown as PrismaClient,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus,
{} as unknown as Models,
{} as unknown as CryptoHelper,
{} as unknown as WorkspacePolicyService,
{} as unknown as EntitlementService,
{
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 4 }),
} as unknown as QuotaStateService
);
await service.updateTeamSeats({
workspaceId: 'ws',
} as Events['workspace.members.updated']);
t.deepEqual(events, [
{
name: 'workspace.members.allocateSeats',
payload: { workspaceId: 'ws', quantity: 4 },
},
]);
});
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const affineProRequests: string[] = [];
const upserts: unknown[] = [];
const entitlements: unknown[] = [];
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => null,
upsert: async (input: unknown) => {
upserts.push(input);
return {
workspaceId: 'ws',
key: 'license-key',
quantity: 3,
recurring: SubscriptionRecurring.Monthly,
variant: null,
};
},
},
} as unknown as PrismaClient,
{
emit: (name: string, payload: unknown) => events.push({ name, payload }),
} as unknown as EventBus,
{} as unknown as Models,
{} as unknown as CryptoHelper,
{} as unknown as WorkspacePolicyService,
{
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
entitlements.push(input);
},
} as unknown as EntitlementService,
{} as unknown as QuotaStateService
);
(
service as unknown as {
fetchAffinePro: (path: string) => Promise<{
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
quantity: number;
endAt: number;
res: Response;
}>;
}
).fetchAffinePro = async (path: string) => {
affineProRequests.push(path);
return {
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
endAt: expiresAt,
res: new Response(null, {
headers: {
'x-next-validate-key': 'next-validate-key',
},
}),
};
};
const license = await service.activateTeamLicense('ws', 'license-key');
t.like(license, {
workspaceId: 'ws',
key: 'license-key',
quantity: 3,
recurring: SubscriptionRecurring.Monthly,
});
t.is(entitlements.length, 1);
t.is(upserts.length, 1);
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
t.deepEqual(events, [
{
name: 'workspace.subscription.activated',
payload: {
workspaceId: 'ws',
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
},
},
]);
});
@@ -86,7 +86,10 @@ test('should cleanup expired pending blobs', async t => {
],
});
const abortSpy = Sinon.spy(t.context.storage, 'abortMultipartUpload');
const abortSpy = Sinon.stub(
t.context.storage,
'abortMultipartUpload'
).resolves();
const deleteSpy = Sinon.spy(t.context.storage, 'delete');
t.teardown(() => {
abortSpy.restore();
@@ -9,7 +9,7 @@ import type { TestingApp } from './utils';
type TestContext = {
app: TestingApp;
};
const test = ava as TestFn<TestContext>;
const test = ava.serial as TestFn<TestContext>;
let safeFetchStub: Sinon.SinonStub | undefined;
let safeFetchHandler:
@@ -3,7 +3,8 @@ import { createHash } from 'node:crypto';
import test from 'ava';
import Sinon from 'sinon';
import { Config, StorageProviderFactory } from '../../base';
import { Config, ConfigFactory, StorageProviderFactory } from '../../base';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
import { BlobModel, WorkspaceFeatureModel } from '../../models';
import {
@@ -35,6 +36,18 @@ let model: WorkspaceFeatureModel;
test.before(async () => {
app = await createTestingApp();
model = app.get(WorkspaceFeatureModel);
app.get(ConfigFactory).override({
storages: {
blob: {
storage: {
provider: 'fs',
bucket: 'test',
config: { path: '/tmp/affine-test-storage' },
},
},
},
});
await app.get(WorkspaceBlobStorage).onConfigInit();
});
test.beforeEach(async () => {
@@ -45,6 +58,26 @@ test.after.always(async () => {
await app.close();
});
async function withRestrictedWorkspaceQuota(workspaceId: string) {
const quotaState = app.get(QuotaStateService);
const blobModel = app.get(BlobModel);
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
async id => {
if (id !== workspaceId) {
return base;
}
return {
...base,
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
};
}
);
}
test('should set blobs', async t => {
await app.signupV1('u1@affine.pro');
@@ -233,7 +266,8 @@ test('should reject blob exceeded limit', async t => {
await app.signupV1('u1@affine.pro');
const workspace1 = await createWorkspace(app);
await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const quotaStub = await withRestrictedWorkspaceQuota(workspace1.id);
t.teardown(() => quotaStub.restore());
const buffer1 = Buffer.from(
Array.from({ length: RESTRICTED_QUOTA.blobLimit + 1 }, () => 0)
@@ -247,7 +281,8 @@ test('should reject blob exceeded storage quota', async t => {
await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app);
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const quotaStub = await withRestrictedWorkspaceQuota(workspace.id);
t.teardown(() => quotaStub.restore());
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
@@ -7,7 +7,9 @@ import Sinon from 'sinon';
import supertest from 'supertest';
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
import { ConfigFactory } from '../../base';
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { PermissionReadModel } from '../../core/permission/config';
import { WorkspaceBlobStorage } from '../../core/storage';
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
import {
@@ -152,6 +154,31 @@ test('should be able to get private workspace with public pages', async t => {
t.is(res.text, 'blob');
});
test('should be able to get private workspace with public pages using new permission model', async t => {
const { app, storage } = t.context;
const config = app.get(ConfigFactory);
config.override({
permission: {
readModel: PermissionReadModel.Projection,
},
});
try {
storage.get.resolves(blob());
const res = await app.GET('/api/workspaces/private/blobs/test');
t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');
} finally {
config.override({
permission: {
readModel: PermissionReadModel.Legacy,
},
});
}
});
test('should not be able to get private workspace with no public pages', async t => {
const { app } = t.context;
+4 -1
View File
@@ -185,7 +185,10 @@ export function buildAppModule(env: Env) {
.useIf(
() => env.flavors.sync || env.flavors.front,
SyncModule,
TelemetryModule,
TelemetryModule
)
.useIf(
() => !env.flavors.graphql && (env.flavors.sync || env.flavors.front),
CopilotRealtimeModule
)
// graphql server only
+1 -1
View File
@@ -10,5 +10,5 @@ import { CacheInterceptor } from './interceptor';
})
export class CacheModule {}
export { Cache, SessionCache };
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
export { isValidCacheTtl } from './provider';
+4
View File
@@ -7,6 +7,10 @@ export interface CacheSetOptions {
ttl?: number;
}
export function isValidCacheTtl(ttl: unknown): ttl is number {
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
}
export class CacheProvider {
constructor(private readonly redis: Redis) {}
@@ -1,6 +1,7 @@
export {
Cache,
CacheInterceptor,
isValidCacheTtl,
MakeCache,
PreventCache,
SessionCache,
@@ -62,6 +62,7 @@ export type KnownMetricScopes =
| 'queue'
| 'storage'
| 'process'
| 'permission'
| 'workspace';
const metricCreators: MetricCreators = {
@@ -9,7 +9,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { ActionForbidden } from '../../base';
import { ActionForbidden, EventBus } from '../../base';
import { Models } from '../../models';
import { CurrentUser } from '../auth/session';
import { UserType } from '../user';
@@ -26,7 +26,10 @@ class GenerateAccessTokenInput {
@Resolver(() => AccessToken)
export class AccessTokenResolver {
constructor(private readonly models: Models) {}
constructor(
private readonly models: Models,
private readonly event: EventBus
) {}
@Query(() => [RevealedAccessToken], {
deprecationReason: 'use currentUser.revealedAccessTokens',
@@ -42,11 +45,13 @@ export class AccessTokenResolver {
@CurrentUser() user: CurrentUser,
@Args('input') input: GenerateAccessTokenInput
): Promise<RevealedAccessToken> {
return await this.models.accessToken.create({
const token = await this.models.accessToken.create({
userId: user.id,
name: input.name,
expiresAt: input.expiresAt,
});
this.event.emit('user.access_token.created', { userId: user.id });
return token;
}
@Mutation(() => Boolean)
@@ -55,6 +60,7 @@ export class AccessTokenResolver {
@Args('id') id: string
): Promise<boolean> {
await this.models.accessToken.revoke(id, user.id);
this.event.emit('user.access_token.revoked', { userId: user.id });
return true;
}
}
@@ -1,12 +1,12 @@
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { z } from 'zod';
import { decodeWithJson, encodeWithJson } from '../../base/graphql';
import { AccessController } from '../permission';
import { PermissionAccess } from '../permission';
import {
realtimeCommentRoom,
type RealtimePublisher,
type RealtimeRegistry,
RealtimePublisher,
RealtimeRegistry,
registerRealtimeLiveQuery,
} from '../realtime';
import type { CommentCursor } from './resolver';
@@ -20,8 +20,8 @@ export function commentRoom(workspaceId: string, docId: string) {
export class CommentRealtimeProvider implements OnModuleInit {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
@Optional() private readonly registry?: RealtimeRegistry
private readonly ac: PermissionAccess,
private readonly registry: RealtimeRegistry
) {}
onModuleInit() {
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto';
import { Optional } from '@nestjs/common';
import {
Args,
Mutation,
@@ -26,8 +25,8 @@ import {
import { Comment, DocMode, Models, Reply } from '../../models';
import { CurrentUser } from '../auth/session';
import { ServerFeature, ServerService } from '../config';
import { AccessController, DocAction } from '../permission';
import type { RealtimePublisher } from '../realtime';
import { DocAction, PermissionAccess } from '../permission';
import { RealtimePublisher } from '../realtime';
import { CommentAttachmentStorage } from '../storage';
import { UserType } from '../user';
import { WorkspaceType } from '../workspaces';
@@ -55,12 +54,12 @@ export interface CommentCursor {
export class CommentResolver {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly commentAttachmentStorage: CommentAttachmentStorage,
private readonly queue: JobQueue,
private readonly models: Models,
private readonly server: ServerService,
@Optional() private readonly realtime?: RealtimePublisher
private readonly realtime: RealtimePublisher
) {
// enable comment feature by default
this.server.enableFeature(ServerFeature.Comment);
@@ -470,11 +469,7 @@ export class CommentResolver {
private async assertPermission(
me: UserType,
item: {
workspaceId: string;
docId: string;
userId?: string;
},
item: { workspaceId: string; docId: string; userId?: string },
action: DocAction
) {
// the owner of the comment/reply can update, delete, resolve it
@@ -173,7 +173,7 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
description: 'Workspace features available for admin configuration',
})
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
return ['unlimited_workspace', 'team_plan_v1'];
return [];
}
}
@@ -11,7 +11,7 @@ import { Models } from '../../models';
import { htmlSanitize } from '../../native';
import { Public } from '../auth';
import { DocReader } from '../doc';
import { WorkspacePolicyService } from '../permission';
import { PermissionService } from '../permission';
interface RenderOptions {
title: string;
@@ -61,7 +61,7 @@ export class DocRendererController {
private readonly doc: DocReader,
private readonly models: Models,
private readonly config: Config,
private readonly policy: WorkspacePolicyService
private readonly permission: PermissionService
) {
this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static'));
this.mobileAssets = this.readHtmlAssets(
@@ -99,10 +99,11 @@ export class DocRendererController {
req.accepts().some(t => markdownType.has(t.toLowerCase()))
) {
try {
const canReadMarkdown = await this.policy.canReadSharedDoc(
const canReadMarkdown = await this.permission.canDoc({
workspaceId,
sub
);
docId: sub,
action: 'Doc.Read',
});
if (!canReadMarkdown) {
res.status(404).end();
return;
@@ -162,7 +163,7 @@ export class DocRendererController {
workspaceId: string,
docId: string
): Promise<RenderOptions | null> {
if (await this.policy.canPreviewDoc(workspaceId, docId)) {
if (await this.permission.canPreviewDoc({ workspaceId, docId })) {
return this.doc.getDocContent(workspaceId, docId);
}
@@ -172,8 +173,9 @@ export class DocRendererController {
private async getWorkspaceContent(
workspaceId: string
): Promise<RenderOptions | null> {
const canPreviewWorkspace =
await this.policy.canPreviewWorkspace(workspaceId);
const canPreviewWorkspace = await this.permission.canPreviewWorkspace({
workspaceId,
});
if (!canPreviewWorkspace) return null;
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
@@ -73,7 +73,7 @@ export class DocStorageOptions implements IDocStorageOptions {
historyMaxAge = async (spaceId: string) => {
const quota = await this.quota.getWorkspaceQuota(spaceId);
return quota.historyPeriod;
return quota.historyPeriod * 1000;
};
historyMinInterval = (_spaceId: string) => {
@@ -0,0 +1,135 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { Models } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import {
EntitlementModule,
EntitlementProjectionChecker,
EntitlementService,
} from '../index';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
entitlement: EntitlementService;
checker: EntitlementProjectionChecker;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({ imports: [EntitlementModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.entitlement = module.get(EntitlementService);
t.context.checker = module.get(EntitlementProjectionChecker);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('checker distinguishes valid projection from dirty legacy features', async t => {
const cleanUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: cleanUser.id,
plan: 'pro',
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const dirtyUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.userFeature.add(
dirtyUser.id,
'pro_plan_v1',
'dirty legacy feature'
);
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.dirtyLegacyUserFeatures, 1);
t.is(report.missingUserFeatureProjection, 0);
});
test('checker reports missing legacy projection and stale state', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: 'pro',
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
await t.context.db.subscription.delete({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
});
await t.context.db.effectiveUserQuotaState.update({
where: { userId: user.id },
data: {
staleAfter: new Date('2020-01-01T00:00:00Z'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.cloudSubscriptionProjectionMissing, 1);
t.is(report.staleEffectiveUserState, 1);
});
test('checker reports legal legacy facts missing entitlements', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.subscription.create({
data: {
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
},
});
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.installedLicense.create({
data: {
key: 'legacy-verifiable-key',
workspaceId: workspace.id,
quantity: 5,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
validatedAt: new Date(),
license: Buffer.from('raw-license'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.cloudSubscriptionEntitlementMissing, 1);
t.is(report.selfhostLicenseEntitlementMissing, 1);
});

Some files were not shown because too many files have changed in this diff Show More