mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
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 --> [](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 -->
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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()
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user