Compare commits

...

24 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
304 changed files with 28256 additions and 5975 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",
Generated
+5 -15
View File
@@ -143,7 +143,6 @@ dependencies = [
"mermaid-rs-renderer",
"objc2",
"objc2-foundation",
"sqlx",
"thiserror 2.0.18",
"tokio",
"typst",
@@ -237,6 +236,7 @@ dependencies = [
"rand 0.9.4",
"rayon",
"reqwest",
"rustls",
"schemars",
"serde",
"serde_json",
@@ -246,6 +246,7 @@ dependencies = [
"tokio",
"url",
"v_htmlescape",
"webpki-roots",
"y-octo",
]
@@ -3747,9 +3748,9 @@ dependencies = [
[[package]]
name = "llm_adapter"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca30267ba36e247d1ff7a916a03db2ceb1de7f0bfcab7250cde006cdda68c19"
checksum = "332397a6ccde5ac47fc32b29a2eed447135eb4ff6fd05ffb88dfe937ea9c8211"
dependencies = [
"base64",
"jsonschema",
@@ -6225,7 +6226,6 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rustls",
"serde",
"serde_json",
"sha2",
@@ -6235,7 +6235,6 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
"webpki-roots 0.26.11",
]
[[package]]
@@ -8048,7 +8047,7 @@ dependencies = [
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots 1.0.6",
"webpki-roots",
]
[[package]]
@@ -8410,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"
+3 -4
View File
@@ -16,10 +16,10 @@ 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"
aes-gcm = "0.10"
anyhow = "1"
arbitrary = { version = "1.3", features = ["derive"] }
assert-json-diff = "2.0"
@@ -40,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",
@@ -81,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" }
@@ -99,8 +101,6 @@ resolver = "3"
screencapturekit = "0.3"
serde = "1"
serde_json = "1"
hex = "0.4"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
sha2 = "0.10"
sha3 = "0.10"
smol_str = "0.3"
@@ -110,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(
@@ -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;
};
+2 -2
View File
@@ -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",
+3 -1
View File
@@ -9,13 +9,13 @@ version = "1.0.0"
crate-type = ["cdylib"]
[dependencies]
aes-gcm = { workspace = true }
affine_common = { workspace = true, features = [
"doc-loader",
"hashcash",
"napi",
"ydoc-loader",
] }
aes-gcm = { workspace = true }
anyhow = { workspace = true }
base64-simd = { workspace = true }
chrono = { workspace = true }
@@ -38,6 +38,7 @@ 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 }
@@ -46,6 +47,7 @@ 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]
@@ -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]
@@ -84,6 +84,10 @@ fn restricted_decision(input: &PermissionEvaluationInputV1, action: &str) -> Vec
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 {
@@ -347,9 +347,12 @@ mod tests {
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]
+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
@@ -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
@@ -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, {
@@ -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,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;
+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;
}
}
@@ -2,7 +2,7 @@ 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,
RealtimePublisher,
@@ -20,7 +20,7 @@ export function commentRoom(workspaceId: string, docId: string) {
export class CommentRealtimeProvider implements OnModuleInit {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly registry: RealtimeRegistry
) {}
@@ -25,7 +25,7 @@ import {
import { Comment, DocMode, Models, Reply } from '../../models';
import { CurrentUser } from '../auth/session';
import { ServerFeature, ServerService } from '../config';
import { AccessController, DocAction } from '../permission';
import { DocAction, PermissionAccess } from '../permission';
import { RealtimePublisher } from '../realtime';
import { CommentAttachmentStorage } from '../storage';
import { UserType } from '../user';
@@ -54,7 +54,7 @@ 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,
@@ -469,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);
});
@@ -0,0 +1,480 @@
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, EntitlementService } from '../index';
import { LegacyEntitlementProjectionService } from '../projection';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
entitlement: EntitlementService;
projection: LegacyEntitlementProjectionService;
}
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.projection = module.get(LegacyEntitlementProjectionService);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('projects user entitlement to legacy user features and subscriptions', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1'));
t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
t.like(
await t.context.db.subscription.findUnique({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
}),
{
recurring: SubscriptionRecurring.Yearly,
status: 'active',
}
);
await t.context.entitlement.revokeCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
});
t.false(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
});
test('projects workspace entitlement and readonly state to legacy workspace features', async t => {
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.entitlement.upsertFromCloudSubscription({
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
quantity: 8,
});
const teamFeature = await t.context.models.workspaceFeature.get(
workspace.id,
'team_plan_v1'
);
t.is(teamFeature?.configs.memberLimit, 8);
await t.context.db.effectiveWorkspaceQuotaState.upsert({
where: {
workspaceId: workspace.id,
},
create: {
workspaceId: workspace.id,
plan: 'free',
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 4,
overcapacityMemberCount: 1,
blobLimit: BigInt(10),
storageQuota: BigInt(10),
usedStorageQuota: BigInt(1),
historyPeriodSeconds: 7,
readonly: true,
readonlyReasons: ['member_overflow'],
known: true,
stale: false,
},
update: {
plan: 'free',
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 4,
overcapacityMemberCount: 1,
blobLimit: BigInt(10),
storageQuota: BigInt(10),
usedStorageQuota: BigInt(1),
historyPeriodSeconds: 7,
readonly: true,
readonlyReasons: ['member_overflow'],
known: true,
stale: false,
},
});
await t.context.projection.onWorkspaceQuotaStateChanged({
workspaceId: workspace.id,
});
t.true(
await t.context.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
);
});
test('installed license scanner never trusts quantity without raw license', async t => {
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-key',
workspaceId: workspace.id,
quantity: 100,
recurring: SubscriptionRecurring.Yearly,
validateKey: '',
validatedAt: new Date(),
},
});
await t.context.projection.scanInstalledLicenses();
const entitlement = await t.context.db.entitlement.findFirst({
where: {
source: 'selfhost_license',
subjectId: 'legacy-key',
},
});
t.is(entitlement?.status, 'needs_reupload');
t.is(entitlement?.quantity, null);
});
test.serial(
'selfhosted legacy projection ignores unknown entitlements',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.entitlement.create({
data: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'ai',
status: 'active',
subjectId: `forged-ai:${user.id}`,
},
});
await t.context.projection.onEntitlementChanged({
targetType: 'user',
targetId: user.id,
});
t.false(
await t.context.models.userFeature.has(user.id, 'unlimited_copilot')
);
t.is(
await t.context.db.subscription.count({ where: { targetId: user.id } }),
0
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
test('backfill marks selfhost team subscriptions as needing license revalidation', async t => {
await t.context.db.subscription.create({
data: {
targetId: 'license-key-target',
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
},
});
await t.context.projection.backfillEntitlementsAndQuotaStates();
t.like(
await t.context.db.entitlement.findFirstOrThrow({
where: {
source: 'selfhost_license',
subjectId: 'license-key-target',
},
}),
{
targetType: 'instance',
targetId: 'license-key-target',
plan: 'selfhost_team',
status: 'needs_reupload',
}
);
});
test('backfill removes dangling legacy subscriptions and entitlements', async t => {
await t.context.db.subscription.createMany({
data: [
{
targetId: randomUUID(),
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
},
{
targetId: randomUUID(),
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
},
],
});
await t.context.db.entitlement.createMany({
data: [
{
targetType: 'user',
targetId: randomUUID(),
source: 'cloud_subscription',
plan: 'pro',
status: 'active',
subjectId: randomUUID(),
},
{
targetType: 'workspace',
targetId: randomUUID(),
source: 'cloud_subscription',
plan: 'team',
status: 'active',
subjectId: randomUUID(),
},
],
});
await t.context.projection.backfillEntitlementsAndQuotaStates();
t.is(await t.context.db.subscription.count(), 0);
t.is(await t.context.db.entitlement.count(), 0);
});
test('key based selfhost entitlements without raw payload need reupload', async t => {
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.entitlement.upsertFromSelfhostLicense({
workspaceId: workspace.id,
licenseKey: 'remote-key',
recurring: SubscriptionRecurring.Yearly,
quantity: 5,
validateKey: 'validate-key',
expiresAt: new Date(Date.now() + 3600_000),
});
await t.context.projection.scanInstalledLicenses();
t.like(
await t.context.db.entitlement.findFirstOrThrow({
where: { source: 'selfhost_license', subjectId: 'remote-key' },
}),
{ status: 'needs_reupload', quantity: null }
);
});
test('revoked selfhost entitlement removes installed license projection', async t => {
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.entitlement.create({
data: {
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'active',
subjectId: 'revoked-key',
quantity: 5,
signedPayload: Buffer.from('signed-license-payload'),
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
},
expiresAt: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
});
await t.context.db.installedLicense.create({
data: {
key: 'revoked-key',
workspaceId: workspace.id,
quantity: 5,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
validatedAt: new Date(),
license: Buffer.from('signed-license-payload'),
},
});
await t.context.entitlement.revokeBySubject(
'selfhost_license',
'revoked-key'
);
t.falsy(
await t.context.db.installedLicense.findUnique({
where: { workspaceId: workspace.id },
})
);
});
test('installed license projection uses explicit entitlement status priority', async t => {
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.entitlement.createMany({
data: [
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'expired',
subjectId: 'expired-key',
quantity: 5,
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'expired-validate-key',
},
expiresAt: new Date(Date.now() - 3600_000),
validatedAt: new Date(),
},
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'grace',
subjectId: 'grace-key',
quantity: 6,
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'grace-validate-key',
},
expiresAt: new Date(Date.now() - 1800_000),
graceUntil: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
],
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
const installedLicense =
await t.context.db.installedLicense.findUniqueOrThrow({
where: { workspaceId: workspace.id },
});
t.is(installedLicense.key, 'grace-key');
t.is(installedLicense.quantity, 6);
t.is(installedLicense.validateKey, 'grace-validate-key');
});
test.serial(
'selfhosted projection does not trust non-null signed payload',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
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.entitlement.create({
data: {
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'active',
subjectId: 'forged-key',
quantity: 100,
signedPayload: Buffer.from('not-a-valid-license'),
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
},
expiresAt: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
t.falsy(
await t.context.models.workspaceFeature.get(
workspace.id,
'team_plan_v1'
)
);
t.falsy(
await t.context.db.installedLicense.findUnique({
where: { workspaceId: workspace.id },
})
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
@@ -0,0 +1,508 @@
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 } from '../index';
import { EntitlementService } from '../service';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
service: EntitlementService;
}
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.service = module.get(EntitlementService);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('upserts admin grant entitlement as commercial source of truth', async t => {
const owner = await t.context.models.user.create({
email: 'admin-grant-owner@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
const entitlement = await t.context.service.upsertAdminGrant({
targetType: 'workspace',
targetId: workspace.id,
plan: 'team',
quantity: 6,
});
const resolved = await t.context.service.resolveWorkspaceEntitlement(
workspace.id
);
t.is(entitlement.source, 'admin_grant');
t.is(entitlement.plan, 'team');
t.is(entitlement.quantity, 6);
t.is(resolved.plan, 'team');
t.is(resolved.quota.seatLimit, 6);
});
test('admin grant replaces and revokes previous admin grant', async t => {
const user = await t.context.models.user.create({
email: 'admin-grant-replace@affine.pro',
});
await t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'lifetime_pro',
});
await t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'pro',
});
const [resolved, entitlements] = await Promise.all([
t.context.service.resolveUserEntitlement(user.id),
t.context.db.entitlement.findMany({
where: { source: 'admin_grant', targetId: user.id },
}),
]);
t.is(resolved.plan, 'pro');
t.is(
entitlements.filter(entitlement => entitlement.status === 'active').length,
1
);
t.false(
entitlements.some(
entitlement =>
entitlement.plan === 'lifetime_pro' && entitlement.status === 'active'
)
);
await t.context.service.revokeAdminGrant('user', user.id);
t.is((await t.context.service.resolveUserEntitlement(user.id)).plan, 'free');
});
test('admin grant rejects self-hosted commercial entitlement without writing', async t => {
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific entitlement semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
const owner = await t.context.models.user.create({
email: 'admin-grant-selfhost@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
try {
await t.throwsAsync(
t.context.service.upsertAdminGrant({
targetType: 'workspace',
targetId: workspace.id,
plan: 'team',
quantity: 6,
}),
{ message: /signed license/ }
);
t.is(
await t.context.db.entitlement.count({
where: { source: 'admin_grant', targetId: workspace.id },
}),
0
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = originalDeploymentType;
}
});
test('admin grant rejects incompatible target plan without writing', async t => {
const user = await t.context.models.user.create({
email: 'admin-grant-invalid@affine.pro',
});
await t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'pro',
});
await t.throwsAsync(
t.context.service.upsertAdminGrant({
targetType: 'user',
targetId: user.id,
plan: 'team',
quantity: 6,
}),
{ message: /not configurable/ }
);
const active = await t.context.db.entitlement.findMany({
where: { source: 'admin_grant', targetId: user.id, status: 'active' },
});
t.is(active.length, 1);
t.is(active[0].plan, 'pro');
});
test('upserts cloud subscription entitlements without writing legacy features', async t => {
const proUser = await t.context.models.user.create({
email: 'user-pro@affine.pro',
});
const aiUser = await t.context.models.user.create({
email: 'user-ai@affine.pro',
});
const owner = await t.context.models.user.create({
email: 'workspace-owner@affine.pro',
});
const teamWorkspace = await t.context.models.workspace.create(owner.id);
const cases = [
{
targetId: proUser.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
expected: { targetType: 'user', plan: 'pro', status: 'active' },
},
{
targetId: aiUser.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'trialing',
expected: { targetType: 'user', plan: 'ai', status: 'active' },
},
{
targetId: teamWorkspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: 'past_due',
quantity: 7,
expected: { targetType: 'workspace', plan: 'team', status: 'grace' },
},
];
for (const item of cases) {
const entitlement = await t.context.service.upsertFromCloudSubscription({
...item,
subscriptionId: `${item.targetId}:${item.plan}`,
start: new Date('2026-05-14T00:00:00Z'),
});
t.like(entitlement, item.expected, item.targetId);
}
t.is(await t.context.db.entitlement.count(), cases.length);
});
test('revokes cloud subscription entitlement by subject', async t => {
const user = await t.context.models.user.create({
email: 'revoke-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
subscriptionId: 'sub_1',
});
await t.context.service.revokeCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
subscriptionId: 'sub_1',
});
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(updated?.status, 'revoked');
});
test('revokes onetime or revenuecat entitlements using fallback subject', async t => {
const user = await t.context.models.user.create({
email: 'fallback-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
});
await t.context.service.revokeCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
subscriptionId: 1,
});
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(updated?.status, 'revoked');
});
test('resolves higher priority commercial entitlement over ai capability', async t => {
const user = await t.context.models.user.create({
email: 'priority-user@affine.pro',
});
await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
});
await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
t.is(resolved.plan, 'pro');
t.is(resolved.quota.storageQuota, 100 * 1024 * 1024 * 1024);
});
test('ignores expired active entitlements during best entitlement selection', async t => {
const user = await t.context.models.user.create({
email: 'expired-user@affine.pro',
});
const cases = [
{
status: 'active',
subjectId: 'expired-subscription',
expiresAt: new Date('2020-01-01T00:00:00Z'),
},
{
status: 'grace',
subjectId: 'open-ended-grace',
},
];
for (const item of cases) {
await t.context.db.entitlement.create({
data: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
...item,
},
});
}
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
const resolved = await t.context.service.resolveUserEntitlement(user.id);
t.is(resolved.plan, 'free');
});
test('selfhosted resolution ignores unsigned DB entitlements', async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific trust boundary
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const user = await t.context.models.user.create({
email: 'forged-user@affine.pro',
});
const owner = await t.context.models.user.create({
email: 'forged-workspace-owner@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
const cases = [
{
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'ai',
quantity: null,
},
{
targetType: 'workspace',
targetId: workspace.id,
source: 'cloud_subscription',
plan: 'team',
quantity: 100,
},
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
quantity: 100,
},
] as const;
for (const item of cases) {
await t.context.db.entitlement.create({
data: {
...item,
status: 'active',
subjectId: `${item.source}:${item.plan}:${item.targetId}`,
quantity: item.quantity ?? undefined,
},
});
}
t.falsy(await t.context.service.getBestEntitlement('user', user.id));
t.falsy(
await t.context.service.getBestEntitlement('workspace', workspace.id)
);
const userResolved = await t.context.service.resolveUserEntitlement(
user.id
);
const workspaceResolved =
await t.context.service.resolveWorkspaceEntitlement(workspace.id);
t.is(userResolved.plan, 'selfhost_free');
t.is(workspaceResolved.plan, 'selfhost_free');
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
});
test('cloud resolution lazily imports legacy subscriptions written after backfill', async t => {
const user = await t.context.models.user.create({
email: 'legacy-subscription-user@affine.pro',
});
await t.context.db.subscription.create({
data: {
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity: 1,
start: new Date(),
},
});
const userResolved = await t.context.service.resolveUserEntitlement(user.id);
const userEntitlement = await t.context.db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
},
});
t.is(userResolved.plan, 'pro');
t.is(userEntitlement?.status, 'active');
const owner = await t.context.models.user.create({
email: 'legacy-subscription-owner@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.subscription.create({
data: {
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity: 7,
start: new Date(),
},
});
const workspaceResolved = await t.context.service.resolveWorkspaceEntitlement(
workspace.id
);
t.is(workspaceResolved.plan, 'team');
t.is(workspaceResolved.quantity, 7);
t.is(workspaceResolved.quota.seatLimit, 7);
await t.context.db.subscription.delete({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const revokedResolved = await t.context.service.resolveUserEntitlement(
user.id
);
const revokedEntitlement = await t.context.db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
},
});
t.is(revokedResolved.plan, 'free');
t.is(revokedEntitlement?.status, 'revoked');
});
test('cloud resolution revokes projected entitlements after legacy subscription deletion', async t => {
const user = await t.context.models.user.create({
email: 'legacy-delete-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
});
await t.context.db.subscription.findUniqueOrThrow({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
await t.context.db.subscription.delete({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(resolved.plan, 'free');
t.is(updated?.status, 'revoked');
});
test('cloud resolution keeps projected string-subscription entitlements while legacy row exists', async t => {
const user = await t.context.models.user.create({
email: 'string-subscription-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
subscriptionId: 'sub_legacy_string',
});
await t.context.db.subscription.findUniqueOrThrow({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(resolved.plan, 'pro');
t.is(updated?.status, 'active');
});
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { LegacyEntitlementProjectionService } from './projection';
import { EntitlementProjectionChecker } from './projection-checker';
import { EntitlementService } from './service';
@Module({
providers: [
EntitlementService,
LegacyEntitlementProjectionService,
EntitlementProjectionChecker,
],
exports: [
EntitlementService,
LegacyEntitlementProjectionService,
EntitlementProjectionChecker,
],
})
export class EntitlementModule {}
export { EntitlementService };
export { EntitlementProjectionChecker };
export { LegacyEntitlementProjectionService };
@@ -0,0 +1,290 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class EntitlementProjectionChecker {
constructor(private readonly db: PrismaClient) {}
async checkEntitlementProjection() {
const now = new Date();
const [
missingEffectiveUserState,
missingEffectiveWorkspaceState,
staleEffectiveUserState,
staleEffectiveWorkspaceState,
cloudSubscriptionProjectionMissing,
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
missingWorkspaceFeatureProjection,
] = await Promise.all([
this.db.user.count({
where: { quotaState: null },
}),
this.db.workspace.count({
where: { quotaState: null },
}),
this.db.effectiveUserQuotaState.count({
where: {
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
},
}),
this.db.effectiveWorkspaceQuotaState.count({
where: {
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
},
}),
this.cloudSubscriptionProjectionMissing(),
this.selfhostLicenseProjectionMissing(),
this.cloudSubscriptionEntitlementMissing(),
this.selfhostLicenseEntitlementMissing(),
this.dirtyLegacyUserFeatures(),
this.dirtyLegacyWorkspaceFeatures(),
this.missingUserFeatureProjection(),
this.missingWorkspaceFeatureProjection(),
]);
return {
missingEffectiveUserState,
missingEffectiveWorkspaceState,
staleEffectiveUserState,
staleEffectiveWorkspaceState,
cloudSubscriptionProjectionMissing,
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
missingWorkspaceFeatureProjection,
};
}
private async cloudSubscriptionProjectionMissing() {
const legacyKeys = new Set(
(
await this.db.subscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
},
select: { targetId: true, plan: true },
})
).map(subscription => `${subscription.targetId}:${subscription.plan}`)
);
const entitlements = await this.validEntitlements({
source: 'cloud_subscription',
});
return entitlements.filter(
entitlement =>
entitlement.targetId &&
!legacyKeys.has(
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
).length;
}
private async selfhostLicenseProjectionMissing() {
const licenseKeys = new Set(
(
await this.db.installedLicense.findMany({
select: { key: true },
})
).map(license => license.key)
);
const entitlements = await this.validEntitlements({
source: 'selfhost_license',
});
return entitlements.filter(
entitlement =>
entitlement.subjectId && !licenseKeys.has(entitlement.subjectId)
).length;
}
private async cloudSubscriptionEntitlementMissing() {
const activeSubscriptions = await this.db.subscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
},
select: { targetId: true, plan: true },
});
const valid = new Set(
(
await this.validEntitlements({
source: 'cloud_subscription',
})
).map(
entitlement =>
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
);
return activeSubscriptions.filter(
subscription =>
!valid.has(`${subscription.targetId}:${subscription.plan}`)
).length;
}
private async selfhostLicenseEntitlementMissing() {
const licenses = await this.db.installedLicense.findMany({
where: {
license: { not: null },
},
select: { key: true },
});
const validKeys = new Set(
(
await this.validEntitlements({
source: 'selfhost_license',
})
).flatMap(entitlement => entitlement.subjectId ?? [])
);
return licenses.filter(license => !validKeys.has(license.key)).length;
}
private async dirtyLegacyUserFeatures() {
const rows = await this.db.userFeature.findMany({
where: {
activated: true,
name: {
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
},
},
select: {
userId: true,
name: true,
},
});
const valid = new Set(
(
await this.validEntitlements({
targetType: 'user',
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
})
).map(entitlement => `${entitlement.targetId}:${entitlement.plan}`)
);
return rows.filter(row => {
const plan =
row.name === 'lifetime_pro_plan_v1'
? 'lifetime_pro'
: row.name === 'pro_plan_v1'
? 'pro'
: 'ai';
return !valid.has(`${row.userId}:${plan}`);
}).length;
}
private async dirtyLegacyWorkspaceFeatures() {
const rows = await this.db.workspaceFeature.findMany({
where: {
activated: true,
name: 'team_plan_v1',
},
select: { workspaceId: true },
});
const validWorkspaceIds = new Set(
(
await this.validEntitlements({
targetType: 'workspace',
plan: { in: ['team', 'selfhost_team'] },
})
).flatMap(entitlement => entitlement.targetId ?? [])
);
return rows.filter(row => !validWorkspaceIds.has(row.workspaceId)).length;
}
private async missingUserFeatureProjection() {
const entitlements = await this.validEntitlements({
targetType: 'user',
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
});
const features = new Set(
(
await this.db.userFeature.findMany({
where: {
activated: true,
name: {
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
},
},
select: { userId: true, name: true },
})
).map(feature => `${feature.userId}:${feature.name}`)
);
return entitlements.filter(entitlement => {
if (!entitlement.targetId) {
return false;
}
const feature =
entitlement.plan === 'lifetime_pro'
? 'lifetime_pro_plan_v1'
: entitlement.plan === 'pro'
? 'pro_plan_v1'
: 'unlimited_copilot';
return !features.has(`${entitlement.targetId}:${feature}`);
}).length;
}
private async missingWorkspaceFeatureProjection() {
const entitlements = await this.validEntitlements({
targetType: 'workspace',
plan: { in: ['team', 'selfhost_team'] },
});
const featureWorkspaceIds = new Set(
(
await this.db.workspaceFeature.findMany({
where: {
activated: true,
name: 'team_plan_v1',
},
select: { workspaceId: true },
})
).map(feature => feature.workspaceId)
);
return entitlements.filter(
entitlement =>
entitlement.targetId && !featureWorkspaceIds.has(entitlement.targetId)
).length;
}
private validEntitlements(where: Record<string, unknown>) {
const now = new Date();
return this.db.entitlement.findMany({
where: {
...where,
...(where.source === 'selfhost_license'
? { signedPayload: { not: null } }
: {}),
OR: [
{
status: 'active',
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
{
status: 'grace',
graceUntil: { gt: now },
},
],
},
select: {
targetId: true,
subjectId: true,
plan: true,
},
});
}
private subscriptionPlan(plan: string) {
return plan === 'lifetime_pro' ? 'pro' : plan;
}
}
@@ -0,0 +1,538 @@
import { Injectable } from '@nestjs/common';
import { Entitlement, PrismaClient } from '@prisma/client';
import { OnEvent } from '../../base';
import { Models } from '../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { EntitlementService } from './service';
type Metadata = {
provider?: string | null;
recurring?: string | null;
variant?: string | null;
subscriptionId?: string | number | null;
stripeSubscriptionId?: string | null;
validateKey?: string | null;
legacyProjected?: boolean;
};
@Injectable()
export class LegacyEntitlementProjectionService {
constructor(
private readonly db: PrismaClient,
private readonly models: Models,
private readonly entitlement: EntitlementService
) {}
@OnEvent('entitlement.changed')
async onEntitlementChanged({
targetType,
targetId,
}: Events['entitlement.changed']) {
if (targetType === 'user') {
await this.#projectCloudSubscriptions('user', targetId);
await this.#projectUserFeatures(targetId);
} else if (targetType === 'workspace') {
await this.#projectCloudSubscriptions('workspace', targetId);
await Promise.all([
this.#projectWorkspaceFeatures(targetId),
this.#projectInstalledLicense(targetId),
]);
}
}
@OnEvent('workspace.quota_state.changed')
async onWorkspaceQuotaStateChanged({
workspaceId,
}: Events['workspace.quota_state.changed']) {
await this.#projectReadonlyFeature(workspaceId);
}
async scanInstalledLicenses() {
const licenses = await this.db.installedLicense.findMany();
await Promise.all(
licenses.map(async license =>
license.license
? await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
license: Buffer.from(license.license),
})
: license.validateKey
? await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
validateKey: license.validateKey,
variant: license.variant,
})
: await this.entitlement.markSelfhostLicenseNeedsReupload({
workspaceId: license.workspaceId,
licenseKey: license.key,
reason: 'Installed license has no raw payload to verify.',
})
)
);
}
async backfillEntitlementsAndQuotaStates() {
await this.#cleanupDanglingLegacyEntitlements();
const [subscriptions, users, workspaces] = await Promise.all([
this.db.subscription.findMany(),
this.db.user.findMany({ select: { id: true } }),
this.db.workspace.findMany({ select: { id: true } }),
]);
for (const subscription of subscriptions) {
if (!(await this.#subscriptionTargetExists(subscription))) {
continue;
}
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
await this.entitlement.markSelfhostLicenseNeedsReupload({
licenseKey: subscription.targetId,
reason:
'Historical self-hosted team subscription needs license activation or revalidation.',
});
continue;
}
await this.entitlement.upsertFromCloudSubscription(subscription);
}
await this.scanInstalledLicenses();
await Promise.all([
...users.map(user =>
this.db.effectiveUserQuotaState.upsert({
where: { userId: user.id },
update: { stale: true },
create: {
userId: user.id,
plan: 'free',
blobLimit: BigInt(0),
storageQuota: BigInt(0),
usedStorageQuota: BigInt(0),
historyPeriodSeconds: 0,
known: false,
stale: true,
},
})
),
...workspaces.map(workspace =>
this.db.effectiveWorkspaceQuotaState.upsert({
where: { workspaceId: workspace.id },
update: { stale: true },
create: {
workspaceId: workspace.id,
plan: 'free',
usesOwnerQuota: true,
seatLimit: 0,
memberCount: 0,
overcapacityMemberCount: 0,
blobLimit: BigInt(0),
storageQuota: BigInt(0),
usedStorageQuota: BigInt(0),
historyPeriodSeconds: 0,
known: false,
stale: true,
},
})
),
]);
}
async #cleanupDanglingLegacyEntitlements() {
await this.db.$executeRaw`
DELETE FROM entitlements entitlement
WHERE (
entitlement.target_type = 'user'
AND NOT EXISTS (
SELECT 1
FROM users
WHERE users.id = entitlement.target_id
)
)
OR (
entitlement.target_type = 'workspace'
AND NOT EXISTS (
SELECT 1
FROM workspaces
WHERE workspaces.id = entitlement.target_id
)
)
`;
await this.db.$executeRaw`
DELETE FROM subscriptions subscription
WHERE (
subscription.plan IN (${SubscriptionPlan.Pro}, ${SubscriptionPlan.AI})
AND NOT EXISTS (
SELECT 1
FROM users
WHERE users.id = subscription.target_id
)
)
OR (
subscription.plan = ${SubscriptionPlan.Team}
AND NOT EXISTS (
SELECT 1
FROM workspaces
WHERE workspaces.id = subscription.target_id
)
)
`;
}
async #subscriptionTargetExists(subscription: {
targetId: string;
plan: string;
}) {
if (
subscription.plan === SubscriptionPlan.Pro ||
subscription.plan === SubscriptionPlan.AI
) {
return !!(await this.db.user.findUnique({
where: { id: subscription.targetId },
select: { id: true },
}));
}
if (subscription.plan === SubscriptionPlan.Team) {
return !!(await this.db.workspace.findUnique({
where: { id: subscription.targetId },
select: { id: true },
}));
}
return true;
}
async #projectUserFeatures(userId: string) {
const entitlements = await this.#activeEntitlements('user', userId);
const quotaEntitlement = entitlements.find(entitlement =>
['lifetime_pro', 'pro'].includes(entitlement.plan)
);
if (quotaEntitlement?.plan === 'lifetime_pro') {
await this.models.userFeature.switchQuota(
userId,
'lifetime_pro_plan_v1',
'legacy entitlement projection'
);
} else if (quotaEntitlement?.plan === 'pro') {
await this.models.userFeature.switchQuota(
userId,
'pro_plan_v1',
'legacy entitlement projection'
);
} else if (
await this.hasActiveUserFeature(userId, [
'pro_plan_v1',
'lifetime_pro_plan_v1',
])
) {
await this.models.userFeature.switchQuota(
userId,
'free_plan_v1',
'legacy entitlement projection'
);
}
if (entitlements.some(entitlement => entitlement.plan === 'ai')) {
await this.models.userFeature.add(
userId,
'unlimited_copilot',
'legacy entitlement projection'
);
} else {
await this.models.userFeature.remove(userId, 'unlimited_copilot');
}
}
async #projectWorkspaceFeatures(workspaceId: string) {
const [entitlement, resolved] = await Promise.all([
this.entitlement.getBestEntitlement('workspace', workspaceId),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
]);
if (
entitlement &&
['team', 'selfhost_team'].includes(resolved.plan) &&
resolved.valid &&
resolved.quota.seatLimit
) {
await this.models.workspaceFeature.add(
workspaceId,
'team_plan_v1',
'legacy entitlement projection',
{
memberLimit: resolved.quota.seatLimit,
}
);
} else {
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
}
}
async #projectCloudSubscriptions(
targetType: 'user' | 'workspace',
targetId: string
) {
if (env.selfhosted) return;
const entitlements = await this.db.entitlement.findMany({
where: {
targetType,
targetId,
source: 'cloud_subscription',
},
orderBy: { updatedAt: 'asc' },
});
for (const entitlement of this.#projectableCloudEntitlements(
entitlements
)) {
const metadata = entitlement.metadata as Metadata;
await this.db.subscription.upsert({
where: {
targetId_plan: {
targetId,
plan: this.#subscriptionPlan(entitlement.plan),
},
},
update: {
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
provider: this.#provider(metadata.provider),
status: this.#subscriptionStatus(entitlement.status),
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
trialEnd: entitlement.graceUntil,
},
create: {
targetId,
plan: this.#subscriptionPlan(entitlement.plan),
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
provider: this.#provider(metadata.provider),
status: this.#subscriptionStatus(entitlement.status),
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
trialEnd: entitlement.graceUntil,
},
});
if (!metadata.legacyProjected) {
await this.db.entitlement.update({
where: { id: entitlement.id },
data: {
metadata: {
...metadata,
legacyProjected: true,
},
},
});
}
}
}
*#projectableCloudEntitlements(entitlements: Entitlement[]) {
const byPlan = new Map<string, Entitlement>();
for (const entitlement of entitlements) {
const plan = this.#subscriptionPlan(entitlement.plan);
const current = byPlan.get(plan);
if (
!current ||
this.#subscriptionProjectionPriority(entitlement) >
this.#subscriptionProjectionPriority(current)
) {
byPlan.set(plan, entitlement);
}
}
yield* byPlan.values();
}
#subscriptionProjectionPriority(entitlement: {
status: string;
updatedAt: Date;
}) {
const statusPriority =
entitlement.status === 'active' || entitlement.status === 'grace'
? 2
: entitlement.status === 'expired'
? 1
: 0;
return (
statusPriority * 10_000_000_000_000 + entitlement.updatedAt.getTime()
);
}
async #projectInstalledLicense(workspaceId: string) {
const [entitlements, resolved] = await Promise.all([
this.db.entitlement.findMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
source: 'selfhost_license',
},
orderBy: [{ signedPayload: 'desc' }, { updatedAt: 'desc' }],
}),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
]);
const entitlement = entitlements.sort(
(left, right) =>
this.#installedLicenseStatusPriority(right.status) -
this.#installedLicenseStatusPriority(left.status) ||
Number(!!right.signedPayload) - Number(!!left.signedPayload) ||
right.updatedAt.getTime() - left.updatedAt.getTime()
)[0];
if (!entitlement) {
return;
}
if (
resolved.plan !== 'selfhost_team' ||
!['active', 'grace', 'expired'].includes(resolved.status)
) {
await this.db.installedLicense.deleteMany({
where: { workspaceId },
});
return;
}
const metadata = entitlement.metadata as Metadata;
const expiredAt = resolved.expiresAt
? new Date(resolved.expiresAt)
: entitlement.expiresAt;
await this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
quantity: resolved.quantity ?? 1,
recurring:
resolved.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
validateKey: metadata.validateKey ?? '',
validatedAt: entitlement.validatedAt ?? new Date(),
expiredAt,
license: entitlement.signedPayload
? Buffer.from(entitlement.signedPayload)
: null,
},
create: {
workspaceId,
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
quantity: resolved.quantity ?? 1,
recurring:
resolved.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
validateKey: metadata.validateKey ?? '',
validatedAt: entitlement.validatedAt ?? new Date(),
expiredAt,
license: entitlement.signedPayload
? Buffer.from(entitlement.signedPayload)
: null,
},
});
}
#installedLicenseStatusPriority(status: string) {
if (status === 'active' || status === 'grace') {
return 3;
}
if (status === 'expired') {
return 2;
}
if (status === 'needs_reupload') {
return 1;
}
return 0;
}
async #projectReadonlyFeature(workspaceId: string) {
const state = await this.db.effectiveWorkspaceQuotaState.findUnique({
where: {
workspaceId,
},
});
if (state?.readonly) {
await this.models.workspaceFeature.add(
workspaceId,
'quota_exceeded_readonly_workspace_v1',
`legacy quota state projection: ${state.readonlyReasons.join(',')}`
);
} else {
await this.models.workspaceFeature.remove(
workspaceId,
'quota_exceeded_readonly_workspace_v1'
);
}
}
async #activeEntitlements(
targetType: 'user' | 'workspace',
targetId: string
) {
return this.entitlement.getActiveEntitlements(targetType, targetId);
}
private async hasActiveUserFeature(userId: string, names: string[]) {
const count = await this.db.userFeature.count({
where: {
userId,
name: { in: names },
activated: true,
},
});
return count > 0;
}
#subscriptionPlan(plan: string) {
if (plan === 'lifetime_pro') {
return SubscriptionPlan.Pro;
}
if (plan === 'selfhost_team') {
return SubscriptionPlan.SelfHostedTeam;
}
return plan;
}
#subscriptionStatus(status: string) {
if (status === 'active') {
return SubscriptionStatus.Active;
}
if (status === 'grace') {
return SubscriptionStatus.PastDue;
}
return SubscriptionStatus.Canceled;
}
#provider(provider: string | null | undefined) {
return provider === 'revenuecat' ? 'revenuecat' : 'stripe';
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { EntitlementModule } from '../entitlement';
import {
AdminFeatureManagementResolver,
UserFeatureResolver,
@@ -7,6 +8,7 @@ import {
import { EarlyAccessType, FeatureService } from './service';
@Module({
imports: [EntitlementModule],
providers: [
UserFeatureResolver,
AdminFeatureManagementResolver,
@@ -1,5 +1,6 @@
import {
Args,
Int,
Mutation,
Parent,
registerEnumType,
@@ -8,13 +9,10 @@ import {
} from '@nestjs/graphql';
import { difference } from 'lodash-es';
import {
Feature,
Models,
type UserFeatureName,
type WorkspaceFeatureName,
} from '../../models';
import { BadRequest, EventBus } from '../../base';
import { Feature, Models, type UserFeatureName } from '../../models';
import { Admin } from '../common';
import { EntitlementService } from '../entitlement';
import { UserType } from '../user/types';
import { AvailableUserFeatureConfig } from './types';
@@ -42,7 +40,11 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig {
@Admin()
@Resolver(() => Boolean)
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
constructor(private readonly models: Models) {
constructor(
private readonly models: Models,
private readonly entitlement: EntitlementService,
private readonly event: EventBus
) {
super();
}
@@ -55,44 +57,58 @@ export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
features: UserFeatureName[]
) {
const configurableUserFeatures = this.configurableUserFeatures();
const unsupported = features.filter(
feature => !configurableUserFeatures.has(feature)
);
if (unsupported.length) {
throw new BadRequest(
`User feature ${unsupported.join(', ')} is not configurable`
);
}
const removed = difference(Array.from(configurableUserFeatures), features);
await Promise.all(
features.map(async feature => {
if (configurableUserFeatures.has(feature)) {
return this.models.userFeature.add(id, feature, 'admin panel');
} else {
return;
}
})
features.map(feature =>
this.models.userFeature.add(id, feature, 'admin panel')
)
);
await Promise.all(
removed.map(feature => this.models.userFeature.remove(id, feature))
);
const user = await this.models.user.get(id);
if (user) {
this.event.emit('user.updated', user);
}
return features;
}
@Mutation(() => Boolean)
async addWorkspaceFeature(
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
async grantCommercialEntitlement(
@Args('targetType', { type: () => String })
targetType: 'user' | 'workspace',
@Args('targetId', { type: () => String }) targetId: string,
@Args('plan', { type: () => String }) plan: string,
@Args('quantity', { type: () => Int, nullable: true }) quantity?: number
) {
await this.models.workspaceFeature.add(
workspaceId,
feature,
'by administrator'
);
await this.entitlement.upsertAdminGrant({
targetType,
targetId,
plan,
quantity,
});
return true;
}
@Mutation(() => Boolean)
async removeWorkspaceFeature(
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
async revokeCommercialEntitlement(
@Args('targetType', { type: () => String })
targetType: 'user' | 'workspace',
@Args('targetId', { type: () => String }) targetId: string
) {
await this.models.workspaceFeature.remove(workspaceId, feature);
await this.entitlement.revokeAdminGrant(targetType, targetId);
return true;
}
}
@@ -5,24 +5,14 @@ import { Feature, UserFeatureName } from '../../models';
@Injectable()
export class AvailableUserFeatureConfig {
availableUserFeatures(): Set<UserFeatureName> {
return new Set([
Feature.Admin,
Feature.UnlimitedCopilot,
Feature.EarlyAccess,
Feature.AIEarlyAccess,
]);
return new Set([Feature.Admin, Feature.EarlyAccess, Feature.AIEarlyAccess]);
}
configurableUserFeatures(): Set<UserFeatureName> {
return new Set(
env.selfhosted
? [Feature.Admin, Feature.UnlimitedCopilot]
: [
Feature.EarlyAccess,
Feature.AIEarlyAccess,
Feature.Admin,
Feature.UnlimitedCopilot,
]
? [Feature.Admin]
: [Feature.EarlyAccess, Feature.AIEarlyAccess, Feature.Admin]
);
}
}
@@ -14,7 +14,7 @@ import {
import { paginate, PaginationInput } from '../../base/graphql';
import { MentionNotificationCreateSchema } from '../../models';
import { CurrentUser } from '../auth/session';
import { AccessController } from '../permission';
import { PermissionAccess } from '../permission';
import { UserType } from '../user';
import { NotificationService } from './service';
import {
@@ -28,7 +28,7 @@ import {
export class UserNotificationResolver {
constructor(
private readonly service: NotificationService,
private readonly ac: AccessController
private readonly ac: PermissionAccess
) {}
@ResolveField(() => PaginatedNotificationObjectType, {
@@ -229,6 +229,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': false,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': false,
'Doc.Delete': false,
'Doc.Duplicate': false,
@@ -251,6 +252,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': false,
@@ -273,6 +275,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': true,
@@ -295,6 +298,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Comments.Update': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': true,
@@ -317,6 +321,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Comments.Update': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -339,6 +344,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Comments.Update': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -361,6 +367,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Comments.Update': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -412,6 +419,7 @@ Generated by [AVA](https://avajs.dev).
'Doc.Comments.Delete': 'Editor',
'Doc.Comments.Read': 'External',
'Doc.Comments.Resolve': 'Editor',
'Doc.Comments.Update': 'Editor',
'Doc.Copy': 'External',
'Doc.Delete': 'Editor',
'Doc.Duplicate': 'Reader',
@@ -10,14 +10,13 @@ import {
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { DocAccessController } from '../doc';
import { PermissionModule } from '../index';
import { PermissionAccess, PermissionModule } from '../index';
import { WorkspacePolicyService } from '../policy';
import { DocRole, mapDocRoleToPermissions } from '../types';
let module: TestingModule;
let models: Models;
let ac: DocAccessController;
let ac: PermissionAccess;
let policy: WorkspacePolicyService;
let user: User;
let ws: Workspace;
@@ -26,7 +25,7 @@ let underReviewUserId: string;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = module.get(DocAccessController);
ac = module.get(PermissionAccess);
policy = module.get(WorkspacePolicyService);
});
@@ -40,6 +39,21 @@ test.after.always(async () => {
await module.close();
});
function doc(resource: {
workspaceId: string;
docId: string;
userId: string;
allowLocal?: boolean;
}) {
const checker = ac
.user(resource.userId)
.doc(resource.workspaceId, resource.docId);
if (resource.allowLocal) {
checker.allowLocal();
}
return checker;
}
const roleCases: Array<{
title: string;
setup?: () => Promise<void>;
@@ -90,7 +104,7 @@ const roleCases: Array<{
expectedRole: DocRole.Owner,
},
{
title: 'should fallback to [External] if workspace is public',
title: 'should not grant private doc role if workspace is public',
setup: async () => {
await models.workspace.update(ws.id, {
public: true,
@@ -101,7 +115,7 @@ const roleCases: Array<{
docId: 'doc1',
userId: 'random-user-id',
}),
expectedRole: DocRole.External,
expectedRole: null,
},
{
title: 'should return null even if workspace has other public doc',
@@ -131,9 +145,13 @@ const roleCases: Array<{
title: 'should return null if doc role is [None]',
setup: async () => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
const u2 = await models.user.create({
email: `${randomUUID()}@affine.pro`,
});
underReviewUserId = u2.id;
await models.workspaceUser.set(
ws.id,
user.id,
underReviewUserId,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
@@ -143,7 +161,7 @@ const roleCases: Array<{
resource: () => ({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
userId: underReviewUserId,
}),
expectedRole: null,
},
@@ -151,14 +169,6 @@ const roleCases: Array<{
title: 'should return [External] if doc role is [None] but doc is public',
setup: async () => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
await models.workspaceUser.set(
ws.id,
user.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.publish(ws.id, 'doc1');
},
resource: () => ({
@@ -174,18 +184,18 @@ for (const roleCase of roleCases) {
test(roleCase.title, async t => {
await roleCase.setup?.();
const resource = roleCase.resource();
const role = await ac.getRole(resource);
const role = (await doc(resource).permissions()).role;
t.is(role, roleCase.expectedRole);
});
}
test('should return mapped permissions', async t => {
const { permissions } = await ac.role({
const { permissions } = await doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
});
}).permissions();
t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner));
});
@@ -195,11 +205,11 @@ test('should deny publish permission when workspace sharing is disabled', async
enableSharing: false,
});
const { permissions } = await ac.role({
const { permissions } = await doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
});
}).permissions();
t.false(permissions['Doc.Publish']);
t.true(permissions['Doc.Read']);
@@ -211,24 +221,18 @@ test('should deny publish assert when workspace sharing is disabled', async t =>
});
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
},
'Doc.Publish'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
}).assert('Doc.Publish')
);
await t.notThrowsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
},
'Doc.Read'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
}).assert('Doc.Read')
);
});
@@ -239,34 +243,27 @@ test('should deny external read assert when sharing is disabled even if doc is p
});
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: 'random-user-id',
},
'Doc.Read'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: 'random-user-id',
}).assert('Doc.Read')
);
});
test('should assert action', async t => {
await t.notThrowsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
},
'Doc.Update'
)
doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
}).assert('Doc.Update')
);
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
await t.throwsAsync(
ac.assert(
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
'Doc.Update'
)
);
@@ -278,8 +275,7 @@ test('should assert action', async t => {
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
doc({ workspaceId: ws.id, docId: 'doc1', userId: u2.id }).assert(
'Doc.Delete'
)
);
@@ -301,11 +297,11 @@ test('should apply readonly doc restrictions while keeping cleanup actions', asy
}
await policy.reconcileWorkspaceQuotaState(ws.id);
const { permissions } = await ac.role({
const { permissions } = await doc({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
});
}).permissions();
t.false(permissions['Doc.Update']);
t.false(permissions['Doc.Publish']);
@@ -1,20 +1,84 @@
import { randomUUID } from 'node:crypto';
import { Prisma, PrismaClient } from '@prisma/client';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import { AccessControllerBuilder } from '../builder';
import { PermissionDiagnosticService } from '../diagnostic';
import { DocRole, PermissionModule, WorkspaceRole } from '../index';
import { PermissionSqlPredicateBuilder } from '../sql-predicate';
import type { DocAction } from '../types';
const module = await createModule({
imports: [PermissionModule],
});
const builder = module.get(AccessControllerBuilder);
const models = module.get(Models);
const db = module.get(PrismaClient);
const diagnostic = module.get(PermissionDiagnosticService);
const sqlPredicate = module.get(PermissionSqlPredicateBuilder);
test.after.always(async () => {
await module.close();
});
async function sqlReadableDocIds(input: {
workspaceId: string;
userId?: string;
action?: DocAction;
docIds: string[];
}) {
const values = Prisma.join(
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
);
const predicate = sqlPredicate.docReadableByNewTablesSql({
workspaceId: input.workspaceId,
userId: input.userId,
action: input.action ?? 'Doc.Read',
docIdColumn: Prisma.raw('c.doc_id'),
});
const rows = await db.$queryRaw<{ docId: string }[]>`
WITH candidates(doc_id, ord) AS (VALUES ${values})
SELECT c.doc_id AS "docId"
FROM candidates c
WHERE ${predicate}
ORDER BY c.ord ASC
`;
return rows.map(row => row.docId);
}
async function resetProjection(workspaceId: string) {
await db.$executeRaw`DELETE FROM doc_grants WHERE workspace_id = ${workspaceId}`;
await db.$executeRaw`DELETE FROM doc_access_policies WHERE workspace_id = ${workspaceId}`;
await db.$executeRaw`DELETE FROM workspace_members WHERE workspace_id = ${workspaceId}`;
await db.$executeRaw`
INSERT INTO workspace_access_policies (
workspace_id,
visibility,
sharing_enabled,
url_preview_enabled,
member_default_doc_role,
updated_at
)
VALUES (${workspaceId}, 'private', true, false, 'none', now())
ON CONFLICT (workspace_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
sharing_enabled = EXCLUDED.sharing_enabled,
url_preview_enabled = EXCLUDED.url_preview_enabled,
member_default_doc_role = EXCLUDED.member_default_doc_role,
updated_at = now()
`;
await models.workspaceRuntimeState.upsert(workspaceId, {
readonly: false,
readonlyReasons: [],
});
}
test('should filter docs by Doc.Read', async t => {
const owner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
@@ -79,11 +143,329 @@ test('should filter docs by Doc.Read', async t => {
t.is(docs3.length, 0);
});
test('SQL doc read predicate matches Rust for projection default and public candidates', async t => {
const owner = await module.create(Mockers.User);
const member = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await resetProjection(workspace.id);
await db.$executeRaw`
UPDATE workspace_access_policies
SET member_default_doc_role = 'reader'
WHERE workspace_id = ${workspace.id}
`;
await db.$executeRaw`
INSERT INTO workspace_members (
workspace_id,
user_id,
role,
state,
source,
updated_at
)
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
`;
await db.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
updated_at
)
VALUES
(${workspace.id}, 'member-default-none', 'private', NULL, 'none', now()),
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now())
`;
const docIds = ['missing-policy', 'member-default-none', 'public-doc'];
const sqlReadable = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: member.id,
docIds,
});
const shadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: member.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sqlReadable,
});
t.deepEqual(sqlReadable, ['missing-policy', 'public-doc']);
t.true(shadow.matched);
});
test('SQL doc read predicate matches Rust for non-member grant and sharing disabled', async t => {
const owner = await module.create(Mockers.User);
const nonMember = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await resetProjection(workspace.id);
await db.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
updated_at
)
VALUES
(${workspace.id}, 'public-doc', 'public', 'external', NULL, now()),
(${workspace.id}, 'private-doc', 'private', NULL, NULL, now()),
(${workspace.id}, 'explicit-grant', 'private', NULL, NULL, now()),
(${workspace.id}, 'explicit-owner-grant', 'private', NULL, NULL, now())
`;
await db.$executeRaw`
INSERT INTO doc_grants (
workspace_id,
doc_id,
principal_type,
principal_id,
role,
updated_at
)
VALUES
(
${workspace.id},
'explicit-grant',
'user',
${nonMember.id},
'reader',
now()
),
(
${workspace.id},
'explicit-owner-grant',
'user',
${nonMember.id},
'owner',
now()
)
`;
const docIds = [
'public-doc',
'private-doc',
'explicit-grant',
'explicit-owner-grant',
];
const sharingEnabledReadable = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: nonMember.id,
docIds,
});
const sharingEnabledShadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: nonMember.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sharingEnabledReadable,
});
const sharingEnabledUpdate = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: nonMember.id,
action: 'Doc.Update',
docIds,
});
await db.$executeRaw`
UPDATE workspace_access_policies
SET sharing_enabled = false
WHERE workspace_id = ${workspace.id}
`;
const sharingDisabledReadable = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: nonMember.id,
docIds,
});
const sharingDisabledShadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: nonMember.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sharingDisabledReadable,
});
t.deepEqual(sharingEnabledReadable, [
'public-doc',
'explicit-grant',
'explicit-owner-grant',
]);
t.true(sharingEnabledShadow.matched);
t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']);
t.deepEqual(sharingDisabledReadable, []);
t.true(sharingDisabledShadow.matched);
});
test('SQL doc predicate suppresses member default when explicit grant exists', async t => {
const owner = await module.create(Mockers.User);
const member = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await resetProjection(workspace.id);
await db.$executeRaw`
UPDATE workspace_access_policies
SET member_default_doc_role = 'manager'
WHERE workspace_id = ${workspace.id}
`;
await db.$executeRaw`
INSERT INTO workspace_members (
workspace_id,
user_id,
role,
state,
source,
updated_at
)
VALUES (${workspace.id}, ${member.id}, 'member', 'active', 'legacy', now())
`;
await db.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
updated_at
)
VALUES
(${workspace.id}, 'default-manager', 'private', NULL, NULL, now()),
(${workspace.id}, 'explicit-reader', 'private', NULL, NULL, now())
`;
await db.$executeRaw`
INSERT INTO doc_grants (
workspace_id,
doc_id,
principal_type,
principal_id,
role,
updated_at
)
VALUES (
${workspace.id},
'explicit-reader',
'user',
${member.id},
'reader',
now()
)
`;
const docIds = ['default-manager', 'explicit-reader'];
const sqlUpdateAllowed = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: member.id,
action: 'Doc.Update',
docIds,
});
t.deepEqual(sqlUpdateAllowed, ['default-manager']);
});
test('legacy SQL doc predicate matches external row and explicit grant cap semantics', async t => {
const workspaceId = randomUUID();
const memberId = randomUUID();
const externalId = randomUUID();
async function fixtureLegacyDocIds(input: {
userId: string;
action: DocAction;
docIds: string[];
}) {
const values = Prisma.join(
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
);
const predicate = sqlPredicate.docReadableByLegacyTablesSql({
workspaceId,
userId: input.userId,
action: input.action,
docIdColumn: Prisma.raw('c.doc_id'),
});
// Current triggers reject newly inserted legacy External workspace rows;
// CTEs let the same predicate run in Postgres against historical shapes.
const rows = await db.$queryRaw<{ docId: string }[]>`
WITH
workspaces(id, enable_sharing) AS (
VALUES (${workspaceId}, true)
),
workspace_pages(workspace_id, page_id, public, "defaultRole") AS (
VALUES
(${workspaceId}, 'default-manager', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'explicit-reader', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'external-owner', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'dirty-external', false, ${DocRole.Manager}::smallint)
),
workspace_user_permissions(
id,
workspace_id,
user_id,
status,
type
) AS (
VALUES
(${randomUUID()}, ${workspaceId}, ${memberId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.Collaborator}::smallint),
(${randomUUID()}, ${workspaceId}, ${externalId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.External}::smallint)
),
workspace_page_user_permissions(
workspace_id,
page_id,
user_id,
type
) AS (
VALUES
(${workspaceId}, 'explicit-reader', ${memberId}, ${DocRole.Reader}::smallint),
(${workspaceId}, 'external-owner', ${externalId}, ${DocRole.Owner}::smallint),
(${workspaceId}, 'dirty-external', ${externalId}, ${DocRole.External}::smallint)
),
candidates(doc_id, ord) AS (VALUES ${values})
SELECT c.doc_id AS "docId"
FROM candidates c
WHERE ${predicate}
ORDER BY c.ord ASC
`;
return rows.map(row => row.docId);
}
const memberUpdateAllowed = await fixtureLegacyDocIds({
userId: memberId,
action: 'Doc.Update',
docIds: ['default-manager', 'explicit-reader'],
});
const externalUpdateAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.Update',
docIds: ['external-owner', 'dirty-external'],
});
const externalManageAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.Users.Manage',
docIds: ['external-owner', 'dirty-external'],
});
const externalTransferAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.TransferOwner',
docIds: ['external-owner', 'dirty-external'],
});
t.deepEqual(memberUpdateAllowed, ['default-manager']);
t.deepEqual(externalUpdateAllowed, ['external-owner']);
t.deepEqual(externalManageAllowed, []);
t.deepEqual(externalTransferAllowed, []);
});
test('should filter docs by Doc.Publish', async t => {
const owner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await models.workspace.update(workspace.id, { enableSharing: true });
await models.workspaceRuntimeState.upsert(workspace.id, {
readonly: false,
readonlyReasons: [],
});
const docs1 = await builder
.user(owner.id)
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
@@ -7,11 +8,6 @@ import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import {
DocActionDenied,
OwnerCanNotLeaveWorkspace,
SpaceAccessDenied,
} from '../../../base';
import {
Models,
User,
@@ -19,25 +15,59 @@ import {
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { QuotaService } from '../../quota/service';
import { QuotaServiceModule } from '../../quota/service.module';
import { QuotaStateService } from '../../quota/state';
import { PermissionModule } from '../index';
import { WorkspacePolicyService } from '../policy';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
policy: WorkspacePolicyService;
}
const test = ava as TestFn<Context>;
const READONLY_FEATURE = 'quota_exceeded_readonly_workspace_v1' as const;
type WorkspaceQuotaSnapshot = Awaited<
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
ReturnType<QuotaStateService['reconcileWorkspaceQuotaState']>
> & {
ownerQuota?: string;
readonlyReasons: string[];
};
const readonlyWorkspaceState = (
workspaceId: string,
readonlyReasons: string[],
overrides: Partial<WorkspaceQuotaSnapshot> = {}
) =>
({
workspaceId,
plan: 'free',
sourceEntitlementId: null,
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 1,
overcapacityMemberCount: readonlyReasons.includes('member_overflow')
? 1
: 0,
blobLimit: BigInt(1),
storageQuota: BigInt(1),
usedStorageQuota: readonlyReasons.includes('storage_overflow')
? BigInt(2)
: BigInt(0),
historyPeriodSeconds: 1,
readonly: readonlyReasons.length > 0,
readonlyReasons,
flags: {},
known: true,
stale: false,
lastReconciledAt: new Date(),
staleAfter: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}) satisfies WorkspaceQuotaSnapshot;
async function addAcceptedMembers(
models: Models,
workspaceId: string,
@@ -64,6 +94,7 @@ let workspace: Workspace;
test.before(async t => {
const module = await createTestingModule({ imports: [PermissionModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.policy = module.get(WorkspacePolicyService);
});
@@ -81,21 +112,23 @@ test.after.always(async t => {
await t.context.module.close();
});
test('should reuse quota service exported by quota service module', async t => {
test('should reuse quota state service exported by quota service module', async t => {
const module = await createTestingModule(
{ imports: [PermissionModule, QuotaServiceModule] },
false
);
try {
const quota = module.select(QuotaServiceModule).get(QuotaService, {
strict: true,
});
const quotaState = module
.select(QuotaServiceModule)
.get(QuotaStateService, {
strict: true,
});
const policy = module.select(PermissionModule).get(WorkspacePolicyService, {
strict: true,
});
t.is(Reflect.get(policy, 'quota'), quota);
t.is(Reflect.get(policy, 'quotaState'), quotaState);
} finally {
await module.close();
}
@@ -108,12 +141,9 @@ test('should keep owned workspace writable when quota is within limit', async t
t.false(state.isReadonly);
t.deepEqual(state.readonlyReasons, []);
t.false(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
});
test('should enter readonly mode when fallback owner member quota overflows', async t => {
test('should report readonly state when fallback owner member quota overflows', async t => {
await addAcceptedMembers(t.context.models, workspace.id, 10);
const state = await t.context.policy.reconcileWorkspaceQuotaState(
@@ -124,91 +154,16 @@ test('should enter readonly mode when fallback owner member quota overflows', as
t.true(state.canRecoverByRemovingMembers);
t.false(state.canRecoverByDeletingBlobs);
t.deepEqual(state.readonlyReasons, ['member_overflow']);
t.true(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
await t.throwsAsync(t.context.policy.assertCanInviteMembers(workspace.id), {
instanceOf: SpaceAccessDenied,
});
});
test('should deny blob uploads when user no longer has write access', async t => {
const external = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
external.id,
WorkspaceRole.External,
{ status: WorkspaceMemberStatus.Accepted }
);
await t.throwsAsync(
t.context.policy.assertCanUploadBlob(external.id, workspace.id),
{ instanceOf: SpaceAccessDenied }
);
});
test('should deny publish through policy when workspace sharing is disabled', async t => {
await t.context.models.workspace.update(workspace.id, {
enableSharing: false,
});
await t.throwsAsync(
t.context.policy.assertCanPublishDoc(owner.id, workspace.id, 'doc1'),
{ instanceOf: DocActionDenied }
);
await t.notThrowsAsync(
t.context.policy.assertCanUnpublishDoc(owner.id, workspace.id, 'doc1')
);
});
test('should allow managers to revoke invite links in readonly workspace', async t => {
await addAcceptedMembers(t.context.models, workspace.id, 10);
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
await t.notThrowsAsync(
t.context.policy.assertCanManageInviteLink(owner.id, workspace.id)
);
});
test('should apply leave workspace policy by role', async t => {
const collaborator = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.Accepted }
);
await t.throwsAsync(
t.context.policy.assertCanLeaveWorkspace(owner.id, workspace.id),
{ instanceOf: OwnerCanNotLeaveWorkspace }
);
await t.notThrowsAsync(
t.context.policy.assertCanLeaveWorkspace(collaborator.id, workspace.id)
);
});
test('should enter readonly mode when fallback owner storage quota overflows', async t => {
const quota = Sinon.stub(
Reflect.get(t.context.policy, 'quota') as QuotaService,
'getWorkspaceQuotaWithUsage'
const quotaState = Sinon.stub(
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
'reconcileWorkspaceQuotaState'
);
quotaState.callsFake(async workspaceId =>
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
);
quota.resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 2,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 2,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
const state = await t.context.policy.reconcileWorkspaceQuotaState(
workspace.id
@@ -218,57 +173,26 @@ test('should enter readonly mode when fallback owner storage quota overflows', a
t.false(state.canRecoverByRemovingMembers);
t.true(state.canRecoverByDeletingBlobs);
t.deepEqual(state.readonlyReasons, ['storage_overflow']);
t.true(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
});
test('should leave readonly mode after workspace usage recovers', async t => {
const quota = Sinon.stub(
Reflect.get(t.context.policy, 'quota') as QuotaService,
'getWorkspaceQuotaWithUsage'
test('should report recovered state after workspace usage recovers', async t => {
const quotaState = Sinon.stub(
Reflect.get(t.context.policy, 'quotaState') as QuotaStateService,
'reconcileWorkspaceQuotaState'
);
quota.onFirstCall().resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 2,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 2,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
quota.onSecondCall().resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 0,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 0,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
quota.onThirdCall().resolves({
name: 'Free',
blobLimit: 1,
storageQuota: 1,
usedStorageQuota: 0,
historyPeriod: 1,
memberLimit: 3,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 0,
ownerQuota: owner.id,
} satisfies WorkspaceQuotaSnapshot);
quotaState
.onFirstCall()
.callsFake(async workspaceId =>
readonlyWorkspaceState(workspaceId, ['storage_overflow'])
);
quotaState
.onSecondCall()
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
quotaState
.onThirdCall()
.callsFake(async workspaceId => readonlyWorkspaceState(workspaceId, []));
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
t.true(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
const recovered = await t.context.policy.reconcileWorkspaceQuotaState(
workspace.id
@@ -276,10 +200,6 @@ test('should leave readonly mode after workspace usage recovers', async t => {
t.false(recovered.isReadonly);
t.deepEqual(recovered.readonlyReasons, []);
t.false(
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
);
await t.notThrowsAsync(t.context.policy.assertCanInviteMembers(workspace.id));
});
test('should roll back team cancellation cleanup when cleanup fails', async t => {
@@ -289,11 +209,58 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
const admin = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
pending.id,
WorkspaceRole.Collaborator
);
await t.context.db.$transaction(async db => {
await db.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
const pendingPermission = await db.workspaceUserRole.create({
data: {
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
});
const [invitationShape] = await db.$queryRaw<Array<{ current: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name = 'requested_role'
) AS "current"
`;
if (invitationShape?.current) {
await db.workspaceInvitation.create({
data: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
requestedRole: 'member',
status: 'pending',
kind: 'email',
legacyPermissionId: pendingPermission.id,
},
});
} else {
await db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
role,
state,
source,
updated_at
)
VALUES (
${workspace.id},
${pending.id},
${'member'},
${'pending'},
${'email'},
now()
)
`;
}
});
await t.context.models.workspaceUser.set(
workspace.id,
admin.id,
File diff suppressed because it is too large Load Diff
@@ -10,14 +10,13 @@ import {
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { PermissionModule } from '../index';
import { PermissionAccess, PermissionModule } from '../index';
import { WorkspacePolicyService } from '../policy';
import { mapWorkspaceRoleToPermissions } from '../types';
import { WorkspaceAccessController } from '../workspace';
let module: TestingModule;
let models: Models;
let ac: WorkspaceAccessController;
let ac: PermissionAccess;
let policy: WorkspacePolicyService;
let user: User;
let ws: Workspace;
@@ -26,7 +25,7 @@ let underReviewUserId: string;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = module.get(WorkspaceAccessController);
ac = module.get(PermissionAccess);
policy = module.get(WorkspacePolicyService);
});
@@ -138,10 +137,34 @@ const roleCases: Array<{
},
];
async function getRole(resource: {
workspaceId: string;
userId: string;
allowLocal?: boolean;
}) {
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
if (resource.allowLocal) {
checker.allowLocal();
}
return (await checker.permissions()).role;
}
function workspace(resource: {
workspaceId: string;
userId: string;
allowLocal?: boolean;
}) {
const checker = ac.user(resource.userId).workspace(resource.workspaceId);
if (resource.allowLocal) {
checker.allowLocal();
}
return checker;
}
for (const roleCase of roleCases) {
test(roleCase.title, async t => {
await roleCase.setup?.();
const role = await ac.getRole(roleCase.resource());
const role = await getRole(roleCase.resource());
t.is(role, roleCase.expectedRole);
});
@@ -150,10 +173,10 @@ for (const roleCase of roleCases) {
test('should return mapped null permission even workspace has public docs', async t => {
await models.doc.publish(ws.id, 'doc1');
const { permissions } = await ac.role({
const { permissions } = await workspace({
workspaceId: ws.id,
userId: 'random-user-id',
});
}).permissions();
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(null));
});
@@ -162,13 +185,10 @@ test('should deny external read assert even workspace has public docs', async t
await models.doc.publish(ws.id, 'doc1');
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
'Workspace.Read'
)
workspace({
workspaceId: ws.id,
userId: 'random-user-id',
}).assert('Workspace.Read')
);
});
@@ -177,13 +197,10 @@ test('should deny external read assert when sharing disabled even if workspace h
await models.workspace.update(ws.id, { enableSharing: false });
await t.throwsAsync(
ac.assert(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
'Workspace.Read'
)
workspace({
workspaceId: ws.id,
userId: 'random-user-id',
}).assert('Workspace.Read')
);
});
@@ -193,31 +210,27 @@ test('should reject external doc roles when sharing disabled', async t => {
enableSharing: false,
});
const [docRole] = await ac.docRoles(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
['doc1']
);
const docRole = await ac
.user('random-user-id')
.doc(ws.id, 'doc1')
.permissions();
t.is(docRole.role, null);
t.false(docRole.permissions['Doc.Read']);
});
test('should return mapped permissions', async t => {
const { permissions } = await ac.role({
const { permissions } = await workspace({
workspaceId: ws.id,
userId: user.id,
});
}).permissions();
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(WorkspaceRole.Owner));
});
test('should assert action', async t => {
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, userId: user.id },
workspace({ workspaceId: ws.id, userId: user.id }).assert(
'Workspace.TransferOwner'
)
);
@@ -225,7 +238,7 @@ test('should assert action', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await t.throwsAsync(
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
workspace({ workspaceId: ws.id, userId: u2.id }).assert('Workspace.Sync')
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Admin, {
@@ -233,8 +246,7 @@ test('should assert action', async t => {
});
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, userId: u2.id },
workspace({ workspaceId: ws.id, userId: u2.id }).assert(
'Workspace.Settings.Update'
)
);
@@ -256,10 +268,10 @@ test('should apply readonly workspace restrictions while keeping cleanup actions
}
await policy.reconcileWorkspaceQuotaState(ws.id);
const { permissions } = await ac.role({
const { permissions } = await workspace({
workspaceId: ws.id,
userId: user.id,
});
}).permissions();
t.false(permissions['Workspace.CreateDoc']);
t.false(permissions['Workspace.Settings.Update']);
@@ -1,26 +1,47 @@
import { Injectable } from '@nestjs/common';
import { DocID } from '../utils/doc';
import { getAccessController } from './controller';
import { Resource } from './resource';
import { DocAction, WorkspaceAction } from './types';
import { WorkspaceAccessController } from './workspace';
import { PermissionService } from './service';
import {
DOC_ACTIONS,
DocAction,
DocRole,
WORKSPACE_ACTIONS,
WorkspaceAction,
WorkspaceRole,
} from './types';
function assertPerm(permission?: PermissionService) {
if (!permission) {
throw new Error('PermissionService is required for permission checks.');
}
return permission;
}
@Injectable()
export class AccessControllerBuilder {
constructor(private readonly permission?: PermissionService) {}
user(userId: string) {
return new UserAccessControllerBuilder(userId);
return new UserAccessControllerBuilder(userId, this.permission);
}
}
export class UserAccessControllerBuilder {
constructor(private readonly userId: string) {}
constructor(
private readonly userId: string,
private readonly permission?: PermissionService
) {}
workspace(workspaceId: string) {
return new WorkspaceAccessControllerBuilder({
userId: this.userId,
workspaceId,
});
return new WorkspaceAccessControllerBuilder(
{
userId: this.userId,
workspaceId,
},
this.permission
);
}
doc(
@@ -45,16 +66,22 @@ export class UserAccessControllerBuilder {
docId = docIdOrWorkspaceId.docId;
}
return new DocAccessControllerBuilder({
userId: this.userId,
workspaceId,
docId,
});
return new DocAccessControllerBuilder(
{
userId: this.userId,
workspaceId,
docId,
},
this.permission
);
}
}
class WorkspaceAccessControllerBuilder {
constructor(public readonly data: Resource<'ws'>) {}
constructor(
public readonly data: Resource<'ws'>,
private readonly permission?: PermissionService
) {}
allowLocal() {
this.data.allowLocal = true;
@@ -62,10 +89,13 @@ class WorkspaceAccessControllerBuilder {
}
doc(docId: string) {
return new DocAccessControllerBuilder({
...this.data,
docId,
});
return new DocAccessControllerBuilder(
{
...this.data,
docId,
},
this.permission
);
}
/**
@@ -79,35 +109,61 @@ class WorkspaceAccessControllerBuilder {
action: DocAction
): Promise<T[]> {
const docIds = items.map(item => item.docId);
const checker = getAccessController('ws') as WorkspaceAccessController;
const docRoles = await checker.docRoles(this.data, docIds);
const docRoles = await assertPerm(this.permission).batchDocPermissions({
userId: this.data.userId,
workspaceId: this.data.workspaceId,
docs: docIds.map(docId => ({
docId,
actions: [action],
})),
allowLocal: this.data.allowLocal,
});
const docRolesMap = new Map(
docRoles.map((role, index) => [docIds[index], role])
);
return items.filter(item => {
return docRolesMap.get(item.docId)?.permissions[action];
return docRolesMap
.get(item.docId)
?.decisions.some(
decision => decision.action === action && decision.allowed
);
});
}
async assert(action: WorkspaceAction) {
const checker = getAccessController('ws');
await checker.assert(this.data, action);
await assertPerm(this.permission).assertWorkspace({
...this.data,
action,
});
}
async can(action: WorkspaceAction) {
const checker = getAccessController('ws');
return await checker.can(this.data, action);
return await assertPerm(this.permission).canWorkspace({
...this.data,
action,
});
}
async permissions() {
const checker = getAccessController('ws');
return await checker.role(this.data);
const result = await assertPerm(this.permission).workspacePermissions({
...this.data,
actions: [...WORKSPACE_ACTIONS],
});
return {
role: result.legacyApiRole as WorkspaceRole | null,
permissions: Object.fromEntries(
result.decisions.map(decision => [decision.action, decision.allowed])
) as Record<WorkspaceAction, boolean>,
};
}
}
class DocAccessControllerBuilder {
constructor(public readonly data: Resource<'doc'>) {}
constructor(
public readonly data: Resource<'doc'>,
private readonly permission?: PermissionService
) {}
allowLocal() {
this.data.allowLocal = true;
@@ -115,17 +171,29 @@ class DocAccessControllerBuilder {
}
async assert(action: DocAction) {
const checker = getAccessController('doc');
await checker.assert(this.data, action);
await assertPerm(this.permission).assertDoc({
...this.data,
action,
});
}
async can(action: DocAction) {
const checker = getAccessController('doc');
return await checker.can(this.data, action);
return await assertPerm(this.permission).canDoc({
...this.data,
action,
});
}
async permissions() {
const checker = getAccessController('doc');
return await checker.role(this.data);
const result = await assertPerm(this.permission).docPermissions({
...this.data,
actions: [...DOC_ACTIONS],
});
return {
role: result.legacyApiRole as DocRole | null,
permissions: Object.fromEntries(
result.decisions.map(decision => [decision.action, decision.allowed])
) as Record<DocAction, boolean>,
};
}
}
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { defineModuleConfig } from '../../base';
export enum PermissionReadModel {
Legacy = 'legacy',
Projection = 'projection',
}
declare global {
interface AppConfigSchema {
permission: {
readModel: PermissionReadModel;
fallbackLegacyLoader: boolean;
};
}
}
defineModuleConfig('permission', {
readModel: {
desc: 'Permission data source for Rust evaluation',
default: PermissionReadModel.Projection,
shape: z.nativeEnum(PermissionReadModel),
env: ['AFFINE_PERMISSION_READ_MODEL', 'string'],
},
fallbackLegacyLoader: {
desc: 'Fallback from projection loader to legacy loader when projection input loading fails',
default: false,
env: ['AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER', 'boolean'],
},
});
@@ -0,0 +1,463 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ClsService } from 'nestjs-cls';
import { DocRole, Models } from '../../models';
import type { PermissionEvaluationInputV1 } from '../../native';
import {
toNativeDocRole,
toNativeExplicitDocGrantRole,
toNativeMemberState,
toNativeWorkspaceRole,
} from './context';
import type { DocAction, WorkspaceAction } from './types';
type PermissionRequestCache = {
workspaceMember: Map<
string,
Awaited<ReturnType<Models['workspaceUser']['get']>>
>;
workspacePolicy: Map<string, Awaited<ReturnType<Models['workspace']['get']>>>;
workspaceRuntime: Map<
string,
Awaited<ReturnType<Models['workspaceRuntimeState']['get']>>
>;
workspaceQuotaRuntime: Map<string, NewWorkspaceRuntimeState>;
docPolicies: Map<
string,
Awaited<ReturnType<Models['doc']['findDefaultRoles']>>
>;
docGrants: Map<string, Awaited<ReturnType<Models['docUser']['findMany']>>>;
};
type NewWorkspaceMemberRow = {
role: 'owner' | 'admin' | 'member';
state: 'active' | 'suspended' | 'left';
};
type NewWorkspacePolicyRow = {
visibility: 'private' | 'public';
sharingEnabled: boolean;
urlPreviewEnabled: boolean;
memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager';
};
type NewDocPolicyRow = {
docId: string;
visibility: 'private' | 'public';
publicRole: 'external' | null;
memberDefaultRole:
| 'none'
| 'reader'
| 'commenter'
| 'editor'
| 'manager'
| null;
urlPreviewEnabled: boolean;
};
type NewDocGrantRow = {
docId: string;
role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
};
type NewWorkspaceRuntimeState = {
known: boolean;
stale: boolean;
readonly: boolean;
readonlyReasons: string[];
staleAfter: Date | null;
};
const CACHE_KEY = 'permission.context.cache';
function createPermissionRequestCache(): PermissionRequestCache {
return {
workspaceMember: new Map(),
workspacePolicy: new Map(),
workspaceRuntime: new Map(),
workspaceQuotaRuntime: new Map(),
docPolicies: new Map(),
docGrants: new Map(),
};
}
export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview';
export type PermissionDocAction = DocAction | 'Doc.Preview';
function cacheKey(parts: readonly unknown[]) {
return parts.join('\0');
}
@Injectable()
export class PermissionContextLoader {
constructor(
private readonly models: Models,
private readonly db: PrismaClient,
private readonly cls?: ClsService
) {}
async load(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
workspaceActions?: PermissionWorkspaceAction[];
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
}): Promise<PermissionEvaluationInputV1> {
const docs = input.docs ?? [];
const [member, workspace, runtime, docPolicies, docGrants] =
await Promise.all([
input.userId
? this.workspaceMember(input.workspaceId, input.userId)
: Promise.resolve(null),
this.workspacePolicy(input.workspaceId),
this.workspaceRuntime(input.workspaceId),
this.docPolicies(
input.workspaceId,
docs.map(doc => doc.docId)
),
input.userId
? this.docGrants(
input.workspaceId,
docs.map(doc => doc.docId),
input.userId
)
: Promise.resolve([]),
]);
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
const workspaceSharingEnabled = workspace?.enableSharing ?? true;
return {
version: 1,
legacyCompatMode: true,
subject: {
userId: input.userId,
groupIds: [],
allowLocal: input.allowLocal,
},
runtime: {
known: runtime.known,
stale: runtime.stale,
readonly: runtime.readonly,
readonlyReason: runtime.readonlyReasons[0],
sharingEnabled: workspaceSharingEnabled,
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
},
workspace: {
role: toNativeWorkspaceRole(member?.type),
memberState: toNativeMemberState(member?.status),
public: workspace?.public ?? false,
sharingEnabled: workspaceSharingEnabled,
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
local: !workspace,
},
workspaceActions: input.workspaceActions,
docs: docs.map((doc, index) => {
const policy = docPolicies[index];
const grant = docGrantMap.get(doc.docId);
return {
docId: doc.docId,
actions: doc.actions,
explicitUserRole: toNativeExplicitDocGrantRole(grant?.type),
groupGrants: [],
groupGrantsEnabled: false,
memberDefaultRole: toNativeDocRole(
policy?.workspace ?? DocRole.Manager
),
publicRole: policy?.external === null ? undefined : 'external',
visibility: policy?.external === null ? 'private' : 'public',
sharingEnabled: workspaceSharingEnabled,
previewEnabled: policy?.external !== null,
};
}),
};
}
async loadFromNewTables(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
workspaceActions?: PermissionWorkspaceAction[];
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
}): Promise<PermissionEvaluationInputV1> {
const docs = input.docs ?? [];
const docIds = docs.map(doc => doc.docId);
const [member, workspacePolicy, runtime, docPolicies, docGrants] =
await Promise.all([
input.userId
? this.newWorkspaceMember(input.workspaceId, input.userId)
: Promise.resolve(null),
this.newWorkspacePolicy(input.workspaceId),
this.newWorkspaceRuntime(input.workspaceId),
this.newDocPolicies(input.workspaceId, docIds),
input.userId
? this.newDocGrants(input.workspaceId, docIds, input.userId)
: Promise.resolve([]),
]);
const docPolicyMap = new Map(
docPolicies.map(policy => [policy.docId, policy])
);
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
const local =
!workspacePolicy &&
!!input.allowLocal &&
!(await this.workspaceExists(input.workspaceId));
const sharingEnabled = workspacePolicy?.sharingEnabled ?? true;
const urlPreviewEnabled = workspacePolicy?.urlPreviewEnabled ?? false;
return {
version: 1,
legacyCompatMode: true,
subject: {
userId: input.userId,
groupIds: [],
allowLocal: input.allowLocal,
},
runtime: {
known: runtime.known,
stale: runtime.stale,
readonly: runtime.readonly,
readonlyReason: runtime.readonlyReasons[0],
sharingEnabled,
urlPreviewEnabled,
},
workspace: {
role: member?.role,
memberState: member?.state === 'active' ? 'active' : undefined,
public: workspacePolicy?.visibility === 'public',
sharingEnabled,
urlPreviewEnabled,
local,
},
workspaceActions: input.workspaceActions,
docs: docs.map(doc => {
const policy = docPolicyMap.get(doc.docId);
const grant = docGrantMap.get(doc.docId);
const visibility = policy?.visibility ?? 'private';
const publicRole = policy?.publicRole ?? undefined;
return {
docId: doc.docId,
actions: doc.actions,
explicitUserRole: grant?.role,
groupGrants: [],
groupGrantsEnabled: false,
memberDefaultRole:
policy?.memberDefaultRole ??
workspacePolicy?.memberDefaultDocRole ??
'manager',
publicRole: publicRole === 'external' ? 'external' : undefined,
visibility,
sharingEnabled,
previewEnabled:
visibility === 'public' ||
policy?.urlPreviewEnabled ||
urlPreviewEnabled,
};
}),
};
}
private get cache(): PermissionRequestCache {
if (!this.cls) {
return createPermissionRequestCache();
}
if (typeof this.cls.isActive === 'function' && !this.cls.isActive()) {
return createPermissionRequestCache();
}
const existing = this.cls.get(CACHE_KEY) as
| PermissionRequestCache
| undefined;
if (existing) {
return existing;
}
const created = createPermissionRequestCache();
this.cls.set(CACHE_KEY, created);
return created;
}
private memo<T>(
map: Map<string, Promise<T> | T>,
key: string,
load: () => Promise<T>
) {
const cached = map.get(key);
if (cached) {
return Promise.resolve(cached);
}
const promise = load();
map.set(key, promise);
return promise;
}
private workspaceMember(workspaceId: string, userId: string) {
return this.memo(
this.cache.workspaceMember,
cacheKey([workspaceId, userId]),
() => this.models.workspaceUser.get(workspaceId, userId)
);
}
private workspacePolicy(workspaceId: string) {
return this.memo(this.cache.workspacePolicy, workspaceId, () =>
this.models.workspace.get(workspaceId)
);
}
private async workspaceRuntime(workspaceId: string) {
return this.memo(this.cache.workspaceRuntime, workspaceId, () =>
this.models.workspaceRuntimeState.get(workspaceId).then(async state => {
if (state.known || !state.stale) {
return state;
}
const quotaState = await this.newWorkspaceRuntime(workspaceId);
if (!quotaState.known) {
return state;
}
return {
workspaceId,
known: quotaState.known,
stale: quotaState.stale,
readonly: quotaState.readonly,
readonlyReasons: quotaState.readonlyReasons,
updatedAt: null,
lastReconciledAt: null,
staleAfter: quotaState.staleAfter,
};
})
);
}
invalidateWorkspaceQuotaRuntime(workspaceId: string) {
this.cache.workspaceQuotaRuntime.delete(workspaceId);
}
private newWorkspaceRuntime(workspaceId: string) {
return this.memo(
this.cache.workspaceQuotaRuntime,
workspaceId,
async () => {
const rows = await this.db.$queryRaw<NewWorkspaceRuntimeState[]>`
SELECT
known,
stale,
readonly,
readonly_reasons AS "readonlyReasons",
stale_after AS "staleAfter"
FROM effective_workspace_quota_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
const state = rows[0];
if (!state) {
return {
known: false,
stale: true,
readonly: false,
readonlyReasons: [],
staleAfter: null,
};
}
return {
...state,
stale:
state.stale ||
(state.staleAfter !== null && state.staleAfter <= new Date()),
};
}
);
}
private docPolicies(workspaceId: string, docIds: string[]) {
const uniqueDocIds = [...new Set(docIds)];
return this.memo(
this.cache.docPolicies,
cacheKey([workspaceId, ...uniqueDocIds]),
() => this.models.doc.findDefaultRoles(workspaceId, uniqueDocIds)
);
}
private docGrants(workspaceId: string, docIds: string[], userId: string) {
const uniqueDocIds = [...new Set(docIds)];
return this.memo(
this.cache.docGrants,
cacheKey([workspaceId, userId, ...uniqueDocIds]),
() => this.models.docUser.findMany(workspaceId, uniqueDocIds, userId)
);
}
private async newWorkspaceMember(workspaceId: string, userId: string) {
const rows = await this.db.$queryRaw<NewWorkspaceMemberRow[]>`
SELECT role, state
FROM workspace_members
WHERE workspace_id = ${workspaceId}
AND user_id = ${userId}
AND state = 'active'
LIMIT 1
`;
return rows[0] ?? null;
}
private async newWorkspacePolicy(workspaceId: string) {
const rows = await this.db.$queryRaw<NewWorkspacePolicyRow[]>`
SELECT
visibility,
sharing_enabled AS "sharingEnabled",
url_preview_enabled AS "urlPreviewEnabled",
member_default_doc_role AS "memberDefaultDocRole"
FROM workspace_access_policies
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
return rows[0] ?? null;
}
async workspaceExists(workspaceId: string) {
const workspace = await this.db.workspace.findUnique({
where: { id: workspaceId },
select: { id: true },
});
return !!workspace;
}
private async newDocPolicies(workspaceId: string, docIds: string[]) {
if (docIds.length === 0) {
return [];
}
return await this.db.$queryRaw<NewDocPolicyRow[]>`
SELECT
doc_id AS "docId",
visibility,
public_role AS "publicRole",
member_default_role AS "memberDefaultRole",
url_preview_enabled AS "urlPreviewEnabled"
FROM doc_access_policies
WHERE workspace_id = ${workspaceId}
AND doc_id = ANY(${[...new Set(docIds)]})
`;
}
private async newDocGrants(
workspaceId: string,
docIds: string[],
userId: string
) {
if (docIds.length === 0) {
return [];
}
return await this.db.$queryRaw<NewDocGrantRow[]>`
SELECT doc_id AS "docId", role
FROM doc_grants
WHERE workspace_id = ${workspaceId}
AND principal_type = 'user'
AND principal_id = ${userId}
AND doc_id = ANY(${[...new Set(docIds)]})
`;
}
}
@@ -0,0 +1,125 @@
import { WorkspaceMemberStatus } from '@prisma/client';
import type {
PermissionDocRole,
PermissionEvaluationInputV1,
PermissionEvaluationOutputV1,
PermissionWorkspaceRole,
} from '../../native';
import { DocRole, WorkspaceRole } from './types';
export type PermissionRuntimeState = NonNullable<
PermissionEvaluationInputV1['runtime']
>;
export type PermissionWorkspaceContext = NonNullable<
PermissionEvaluationInputV1['workspace']
>;
export type PermissionDocContext = NonNullable<
NonNullable<PermissionEvaluationInputV1['docs']>[number]
>;
export type PermissionLegacyRoleBoundary = {
resourceOwnerRole: PermissionDocRole | PermissionWorkspaceRole | null;
effectiveRole: PermissionDocRole | PermissionWorkspaceRole | null;
legacyApiRole: DocRole | WorkspaceRole | null;
};
const WORKSPACE_ROLE_TO_NATIVE = new Map<
WorkspaceRole,
PermissionWorkspaceRole
>([
[WorkspaceRole.External, 'external'],
[WorkspaceRole.Collaborator, 'member'],
[WorkspaceRole.Admin, 'admin'],
[WorkspaceRole.Owner, 'owner'],
]);
const DOC_ROLE_TO_NATIVE = new Map<DocRole, PermissionDocRole>([
[DocRole.None, 'none'],
[DocRole.External, 'external'],
[DocRole.Reader, 'reader'],
[DocRole.Commenter, 'commenter'],
[DocRole.Editor, 'editor'],
[DocRole.Manager, 'manager'],
[DocRole.Owner, 'owner'],
]);
const NATIVE_WORKSPACE_ROLE_TO_LEGACY = new Map<
PermissionWorkspaceRole,
WorkspaceRole
>([
['external', WorkspaceRole.External],
['member', WorkspaceRole.Collaborator],
['admin', WorkspaceRole.Admin],
['owner', WorkspaceRole.Owner],
]);
const NATIVE_DOC_ROLE_TO_LEGACY = new Map<PermissionDocRole, DocRole>([
['none', DocRole.None],
['external', DocRole.External],
['reader', DocRole.Reader],
['commenter', DocRole.Commenter],
['editor', DocRole.Editor],
['manager', DocRole.Manager],
['owner', DocRole.Owner],
]);
export function toNativeWorkspaceRole(role: WorkspaceRole | null | undefined) {
return role == null ? undefined : WORKSPACE_ROLE_TO_NATIVE.get(role);
}
export function toNativeDocRole(role: DocRole | null | undefined) {
return role == null ? undefined : DOC_ROLE_TO_NATIVE.get(role);
}
export function toNativeExplicitDocGrantRole(role: DocRole | null | undefined) {
if (role === DocRole.None || role === DocRole.External) {
return undefined;
}
return toNativeDocRole(role);
}
export function toNativeMemberState(status?: WorkspaceMemberStatus | null) {
switch (status) {
case WorkspaceMemberStatus.Accepted:
return 'active';
case WorkspaceMemberStatus.UnderReview:
return 'waiting_review';
case WorkspaceMemberStatus.AllocatingSeat:
case WorkspaceMemberStatus.NeedMoreSeat:
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
return 'waiting_seat';
case WorkspaceMemberStatus.Pending:
return 'pending';
default:
return undefined;
}
}
export function workspaceLegacyBoundary(
workspace: PermissionEvaluationOutputV1['workspace']
): PermissionLegacyRoleBoundary {
const effectiveRole = workspace.effectiveRole ?? null;
return {
resourceOwnerRole: workspace.resourceOwnerRole ?? null,
effectiveRole,
legacyApiRole: effectiveRole
? (NATIVE_WORKSPACE_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
: null,
};
}
export function docLegacyBoundary(
doc: PermissionEvaluationOutputV1['docs'][number]
): PermissionLegacyRoleBoundary {
const effectiveRole = doc.effectiveRole ?? null;
return {
resourceOwnerRole: doc.resourceOwnerRole ?? null,
effectiveRole,
legacyApiRole: effectiveRole
? (NATIVE_DOC_ROLE_TO_LEGACY.get(effectiveRole) ?? null)
: null,
};
}
@@ -1,53 +0,0 @@
import { Logger, OnModuleInit } from '@nestjs/common';
import type {
Resource,
ResourceAction,
ResourceRole,
ResourceType,
} from './resource';
const ACTION_CHECKER_PROVIDERS = new Map<ResourceType, AccessController<any>>();
function registerAccessController<Type extends ResourceType>(
type: Type,
provider: AccessController<Type>
) {
ACTION_CHECKER_PROVIDERS.set(type, provider);
}
export function getAccessController<Type extends ResourceType>(
type: Type
): AccessController<Type> {
const provider = ACTION_CHECKER_PROVIDERS.get(type);
if (!provider) {
throw new Error(`No action checker provider for type ${type}`);
}
return provider;
}
export abstract class AccessController<
Type extends ResourceType,
> implements OnModuleInit {
protected abstract readonly type: Type;
protected logger = new Logger(AccessController.name);
onModuleInit() {
registerAccessController(this.type, this);
}
abstract assert(
resource: Resource<Type>,
action: ResourceAction<Type>
): Promise<void>;
abstract can(
resource: Resource<Type>,
action: ResourceAction<Type>
): Promise<boolean>;
abstract role(resource: Resource<Type>): Promise<{
role: ResourceRole<Type> | null;
permissions: Record<ResourceAction<Type>, boolean>;
}>;
}
@@ -0,0 +1,326 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { metrics } from '../../base';
import type { PermissionEvaluationOutputV1 } from '../../native';
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
import {
PermissionContextLoader,
type PermissionDocAction,
type PermissionWorkspaceAction,
} from './context-loader';
import { PermissionService } from './service';
import { PermissionSqlPredicateBuilder } from './sql-predicate';
export const PERMISSION_SHADOW_MISMATCH_CATEGORIES = [
'legacy_compat_delta',
'projection',
'rust_rule',
'loader',
'sql_predicate',
'legacy_api_role_mapping',
'preview_read_mapping',
'runtime_state',
'projection_or_loader',
] as const;
type PermissionShadowMismatchCategory =
(typeof PERMISSION_SHADOW_MISMATCH_CATEGORIES)[number];
@Injectable()
export class PermissionDiagnosticService {
constructor(
private readonly loader: PermissionContextLoader,
private readonly permission: PermissionService,
@Optional()
@Inject(PermissionSqlPredicateBuilder)
private readonly sqlPredicate = new PermissionSqlPredicateBuilder()
) {}
async shadowDocPermissions(input: {
userId?: string;
workspaceId: string;
docs: Array<{ docId: string; actions: PermissionDocAction[] }>;
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const [legacyOutput, newOutput] = await Promise.all([
this.loader.load(input).then(input => this.permission.evaluate(input)),
this.loader
.loadFromNewTables(input)
.then(input => this.permission.evaluate(input)),
]);
const legacy = legacyOutput.docs.map(doc => ({
docId: doc.docId,
...docLegacyBoundary(doc),
decisions: doc.decisions,
}));
const current = newOutput.docs.map(doc => ({
docId: doc.docId,
...docLegacyBoundary(doc),
decisions: doc.decisions,
}));
const matched = JSON.stringify(legacy) === JSON.stringify(current);
const mismatchType = matched
? null
: (input.expectedDeltaCategory ??
this.classifyDocShadowMismatch(legacy, current));
this.recordShadowMismatch('doc', mismatchType);
return {
matched,
legacy,
current,
mismatchType,
};
}
async shadowWorkspacePermissions(input: {
userId?: string;
workspaceId: string;
actions: PermissionWorkspaceAction[];
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const legacyInput = {
userId: input.userId,
workspaceId: input.workspaceId,
workspaceActions: input.actions,
allowLocal: input.allowLocal,
};
const [legacyOutput, newOutput] = await Promise.all([
this.loader
.load(legacyInput)
.then(input => this.permission.evaluate(input)),
this.loader
.loadFromNewTables(legacyInput)
.then(input => this.permission.evaluate(input)),
]);
const legacy = {
...workspaceLegacyBoundary(legacyOutput.workspace),
decisions: legacyOutput.workspace.decisions,
};
const current = {
...workspaceLegacyBoundary(newOutput.workspace),
decisions: newOutput.workspace.decisions,
};
const matched = JSON.stringify(legacy) === JSON.stringify(current);
const mismatchType = matched
? null
: (input.expectedDeltaCategory ??
this.classifyShadowMismatch(legacyOutput, newOutput));
this.recordShadowMismatch('workspace', mismatchType);
return {
matched,
legacy,
current,
mismatchType,
};
}
async shadowSqlDocRead(input: {
userId: string;
workspaceId: string;
docs: Array<{ docId: string }>;
sqlReadableDocIds: string[];
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const rustOutput = this.permission.evaluate(
await this.loader.loadFromNewTables({
userId: input.userId,
workspaceId: input.workspaceId,
docs: input.docs.map(doc => ({
docId: doc.docId,
actions: ['Doc.Read'],
})),
allowLocal: input.allowLocal,
})
);
const rustReadable = new Set(
rustOutput.docs
.filter(doc => doc.decisions[0]?.allowed)
.map(doc => doc.docId)
);
const sqlReadable = new Set(input.sqlReadableDocIds);
const missingInSql = [...rustReadable].filter(id => !sqlReadable.has(id));
const extraInSql = [...sqlReadable].filter(id => !rustReadable.has(id));
const mismatchType =
missingInSql.length || extraInSql.length
? (input.expectedDeltaCategory ?? 'sql_predicate')
: null;
this.recordShadowMismatch('sql_predicate', mismatchType);
return {
matched: mismatchType === null,
predicate: this.sqlPredicate.docReadableByNewTables({
workspaceId: input.workspaceId,
userId: input.userId,
action: 'Doc.Read',
}),
rustReadableDocIds: [...rustReadable],
sqlReadableDocIds: [...sqlReadable],
missingInSql,
extraInSql,
mismatchType,
};
}
async shadowPreviewDoc(input: {
userId?: string;
workspaceId: string;
docId: string;
allowLocal?: boolean;
}) {
const result = await this.shadowDocPermissions({
...input,
docs: [{ docId: input.docId, actions: ['Doc.Preview', 'Doc.Read'] }],
});
const legacy = result.legacy[0];
const current = result.current[0];
const legacyPreviewAllowed = legacy?.decisions.find(
decision => decision.action === 'Doc.Preview'
)?.allowed;
const legacyReadAllowed = legacy?.decisions.find(
decision => decision.action === 'Doc.Read'
)?.allowed;
const previewAllowed = current?.decisions.find(
decision => decision.action === 'Doc.Preview'
)?.allowed;
const readAllowed = current?.decisions.find(
decision => decision.action === 'Doc.Read'
)?.allowed;
const mismatchType =
legacyPreviewAllowed !== previewAllowed ||
(previewAllowed && readAllowed && !legacyReadAllowed)
? 'preview_read_mapping'
: result.mismatchType;
this.recordShadowMismatch('preview', mismatchType);
return {
...result,
matched: result.matched && mismatchType === null,
mismatchType,
};
}
async shadowPreviewWorkspace(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
}) {
const result = await this.shadowWorkspacePermissions({
...input,
actions: ['Workspace.Preview', 'Workspace.Read'],
});
const legacyPreviewAllowed = result.legacy.decisions.find(
decision => decision.action === 'Workspace.Preview'
)?.allowed;
const legacyReadAllowed = result.legacy.decisions.find(
decision => decision.action === 'Workspace.Read'
)?.allowed;
const previewAllowed = result.current.decisions.find(
decision => decision.action === 'Workspace.Preview'
)?.allowed;
const readAllowed = result.current.decisions.find(
decision => decision.action === 'Workspace.Read'
)?.allowed;
const mismatchType =
legacyPreviewAllowed !== previewAllowed ||
(previewAllowed && readAllowed && !legacyReadAllowed)
? 'preview_read_mapping'
: result.mismatchType;
this.recordShadowMismatch('preview', mismatchType);
return {
...result,
matched: result.matched && mismatchType === null,
mismatchType,
};
}
private classifyShadowMismatch(
legacyOutput: PermissionEvaluationOutputV1,
newOutput: PermissionEvaluationOutputV1
) {
if (JSON.stringify(legacyOutput) === JSON.stringify(newOutput)) {
return null;
}
const legacyRestrictions =
JSON.stringify(legacyOutput).includes('runtime_');
const newRestrictions = JSON.stringify(newOutput).includes('runtime_');
if (legacyRestrictions || newRestrictions) {
return 'runtime_state';
}
if (legacyOutput.docs.length !== newOutput.docs.length) {
return 'loader';
}
if (JSON.stringify(legacyOutput.docs) !== JSON.stringify(newOutput.docs)) {
return 'rust_rule';
}
return 'projection';
}
private classifyDocShadowMismatch(
legacy: Array<
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
>,
current: Array<
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
>
) {
if (JSON.stringify(legacy) === JSON.stringify(current)) {
return null;
}
const legacyApi = legacy.map(doc => ({
effectiveRole: doc.effectiveRole,
legacyApiRole: doc.legacyApiRole,
resourceOwnerRole: doc.resourceOwnerRole,
}));
const currentApi = current.map(doc => ({
effectiveRole: doc.effectiveRole,
legacyApiRole: doc.legacyApiRole,
resourceOwnerRole: doc.resourceOwnerRole,
}));
if (JSON.stringify(legacyApi) !== JSON.stringify(currentApi)) {
return 'legacy_api_role_mapping';
}
if (
JSON.stringify(legacy).includes('runtime_') ||
JSON.stringify(current).includes('runtime_')
) {
return 'runtime_state';
}
if (legacy.length !== current.length) {
return 'loader';
}
const legacyDecisions = legacy.map(doc => doc.decisions);
const currentDecisions = current.map(doc => doc.decisions);
if (JSON.stringify(legacyDecisions) !== JSON.stringify(currentDecisions)) {
return 'rust_rule';
}
return 'projection';
}
private recordShadowMismatch(
scope: string,
category: PermissionShadowMismatchCategory | null
) {
if (!category) {
return;
}
metrics.permission
.counter('shadow_mismatches', {
description: 'Permission shadow-read mismatch count',
})
.add(1, { scope, category });
}
}
@@ -1,75 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DocActionDenied } from '../../base';
import { AccessController, getAccessController } from './controller';
import { WorkspacePolicyService } from './policy';
import type { Resource } from './resource';
import {
DocAction,
docActionRequiredRole,
DocRole,
mapDocRoleToPermissions,
} from './types';
import { WorkspaceAccessController } from './workspace';
@Injectable()
export class DocAccessController extends AccessController<'doc'> {
protected readonly type = 'doc';
constructor(private readonly policy: WorkspacePolicyService) {
super();
}
async role(resource: Resource<'doc'>) {
const role = await this.getRole(resource);
const permissions = await this.policy.applyDocPermissions(
resource.workspaceId,
mapDocRoleToPermissions(role)
);
const sharingAllowed = await this.policy.canPublishDoc(
resource.workspaceId
);
if (!sharingAllowed) {
permissions['Doc.Publish'] = false;
}
return { role, permissions };
}
async can(resource: Resource<'doc'>, action: DocAction) {
const { permissions, role } = await this.role(resource);
const allow = permissions[action] || false;
if (!allow) {
this.logger.debug('Doc access check failed', {
action,
resource,
role,
requiredRole: docActionRequiredRole(action),
});
}
return allow;
}
async assert(resource: Resource<'doc'>, action: DocAction) {
const allow = await this.can(resource, action);
if (!allow) {
throw new DocActionDenied({
docId: resource.docId,
spaceId: resource.workspaceId,
action,
});
}
}
async getRole(payload: Resource<'doc'>): Promise<DocRole | null> {
const workspaceController = getAccessController(
'ws'
) as WorkspaceAccessController;
const docRoles = await workspaceController.getDocRoles(payload, [
payload.docId,
]);
return docRoles[0];
}
}

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