Files
AFFiNE-Mirror/packages/backend/server/src/models/workspace-runtime-state.ts
T
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

224 lines
5.7 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { BaseModel } from './base';
export type WorkspaceRuntimeState = {
workspaceId: string;
known: boolean;
stale: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date | null;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type WorkspaceRuntimeStateRow = {
workspaceId: string;
known: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type LegacyWorkspaceRuntimeStateRow = {
workspaceId: string;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
staleAt: Date | null;
};
function isMissingRuntimeStateColumn(error: unknown) {
const meta = (error as { meta?: { code?: string } })?.meta;
return meta?.code === '42703';
}
@Injectable()
export class WorkspaceRuntimeStateModel extends BaseModel {
private hasCurrentColumns?: Promise<boolean>;
async get(workspaceId: string): Promise<WorkspaceRuntimeState> {
const rows = await this.loadRows(workspaceId);
const row = rows[0];
if (!row) {
return {
workspaceId,
known: false,
stale: true,
readonly: false,
readonlyReasons: [],
updatedAt: null,
lastReconciledAt: null,
staleAfter: null,
};
}
return {
workspaceId,
known: row.known,
stale:
!row.known || (row.staleAfter !== null && row.staleAfter <= new Date()),
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.lastReconciledAt,
staleAfter: row.staleAfter,
};
}
async upsert(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
if (await this.supportsCurrentRuntimeStateColumns()) {
await this.upsertCurrent(workspaceId, state);
} else {
await this.upsertLegacy(workspaceId, state);
}
}
private async loadRows(workspaceId: string) {
if (!(await this.supportsCurrentRuntimeStateColumns())) {
return await this.loadLegacyRows(workspaceId);
}
try {
return await this.db.$queryRaw<WorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
known,
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
last_reconciled_at AS "lastReconciledAt",
stale_after AS "staleAfter"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
} catch (error) {
if (!isMissingRuntimeStateColumn(error)) {
throw error;
}
return await this.loadLegacyRows(workspaceId);
}
}
private async supportsCurrentRuntimeStateColumns() {
this.hasCurrentColumns ??= 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"
`.then(rows => rows[0]?.exists ?? false);
return await this.hasCurrentColumns;
}
private async loadLegacyRows(workspaceId: string) {
const rows = await this.db.$queryRaw<LegacyWorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
stale_at AS "staleAt"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
return rows.map(row => ({
workspaceId: row.workspaceId,
known: true,
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.updatedAt,
staleAfter: row.staleAt,
}));
}
private async upsertCurrent(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (
${workspaceId},
${state.known ?? true},
${state.readonly},
${state.readonlyReasons},
${state.lastReconciledAt ?? new Date()},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
known = EXCLUDED.known,
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
last_reconciled_at = EXCLUDED.last_reconciled_at,
stale_after = EXCLUDED.stale_after,
updated_at = now()
`;
}
private async upsertLegacy(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (
${workspaceId},
${state.readonly},
${state.readonlyReasons},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
stale_at = EXCLUDED.stale_at,
updated_at = now()
`;
}
}