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 -->
This commit is contained in:
DarkSky
2026-05-21 15:28:51 +08:00
committed by GitHub
parent d20dbfd6a2
commit dd1cd77ca0
4 changed files with 324 additions and 33 deletions
@@ -0,0 +1,92 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
import { Models } from '../../models';
import { BackfillPermissionProjection1765500000000 } from '../migrations/1765500000000-backfill-permission-projection';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
}
const test = ava as TestFn<Context>;
test.before(async t => {
t.context.module = await createTestingModule();
t.context.db = t.context.module.get(PrismaClient);
t.context.models = t.context.module.get(Models);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('permission backfill repairs ownerless workspaces before runtime state projection', async t => {
const emptyWorkspace = await t.context.db.workspace.create({
data: { public: false },
});
const member = await t.context.models.user.create({
email: 'member@affine.pro',
});
const memberWorkspace = await t.context.db.workspace.create({
data: { public: false },
});
await t.context.db.workspaceMember.create({
data: {
workspaceId: memberWorkspace.id,
userId: member.id,
role: 'member',
state: 'active',
source: 'legacy',
},
});
const ref = {
get(token: unknown) {
if (token === Models) {
return t.context.models;
}
return {
async getWorkspaceState() {
return {
isReadonly: false,
readonlyReasons: [],
};
},
};
},
} as unknown as ModuleRef;
await BackfillPermissionProjection1765500000000.up(t.context.db, ref);
t.is(
await t.context.db.workspace.count({ where: { id: emptyWorkspace.id } }),
0
);
t.like(
await t.context.db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: memberWorkspace.id,
userId: member.id,
state: 'active',
},
}),
{ role: 'owner' }
);
t.like(
await t.context.db.workspaceUserRole.findFirstOrThrow({
where: {
workspaceId: memberWorkspace.id,
userId: member.id,
},
}),
{ type: 99 }
);
});
@@ -5,12 +5,14 @@ import { WorkspacePolicyService } from '../../core/permission/policy';
import { Models } from '../../models';
export class BackfillPermissionProjection1765500000000 {
static async up(_db: PrismaClient, ref: ModuleRef) {
static async up(db: PrismaClient, ref: ModuleRef) {
const models = ref.get(Models, { strict: false });
await models.permissionProjection.backfillLegacyProjection();
await ensureWorkspaceAdminStatsDirtyTriggerGuard(db);
await repairOwnerlessWorkspaces(db);
const policy = ref.get(WorkspacePolicyService, { strict: false });
const workspaces = await _db.workspace.findMany({
const workspaces = await db.workspace.findMany({
select: { id: true },
});
for (const workspace of workspaces) {
@@ -26,3 +28,81 @@ export class BackfillPermissionProjection1765500000000 {
static async down(_db: PrismaClient) {}
}
async function ensureWorkspaceAdminStatsDirtyTriggerGuard(db: PrismaClient) {
await db.$executeRaw`
CREATE OR REPLACE FUNCTION workspace_admin_stats_mark_dirty() RETURNS TRIGGER AS $$
DECLARE
wid VARCHAR;
BEGIN
wid := COALESCE(NEW."workspace_id", OLD."workspace_id");
IF wid IS NULL THEN
RETURN NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM "workspaces" WHERE "id" = wid) THEN
RETURN NULL;
END IF;
INSERT INTO "workspace_admin_stats_dirty" ("workspace_id", "updated_at")
VALUES (wid, NOW())
ON CONFLICT ("workspace_id")
DO UPDATE SET "updated_at" = EXCLUDED."updated_at";
RETURN NULL;
END;
$$ LANGUAGE plpgsql
`;
}
async function repairOwnerlessWorkspaces(db: PrismaClient) {
await db.$executeRaw`
WITH ownerless AS (
SELECT w.id
FROM workspaces w
WHERE NOT EXISTS (
SELECT 1
FROM workspace_members owner
WHERE owner.workspace_id = w.id
AND owner.role = 'owner'
AND owner.state = 'active'
)
),
accepted_members AS (
SELECT id
FROM (
SELECT
wm.id,
row_number() OVER (
PARTITION BY wm.workspace_id
ORDER BY wm.created_at ASC, wm.id ASC
) AS rn
FROM workspace_members wm
JOIN ownerless o ON o.id = wm.workspace_id
WHERE wm.state = 'active'
) ranked
WHERE rn = 1
)
UPDATE workspace_members wm
SET role = 'owner', updated_at = now()
FROM accepted_members am
WHERE wm.id = am.id
`;
await db.$executeRaw`
DELETE FROM workspaces w
WHERE NOT EXISTS (
SELECT 1
FROM workspace_members owner
WHERE owner.workspace_id = w.id
AND owner.role = 'owner'
AND owner.state = 'active'
)
AND NOT EXISTS (
SELECT 1
FROM workspace_members member
WHERE member.workspace_id = w.id
AND member.state = 'active'
)
`;
}