mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
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 --> [](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:
@@ -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 }
|
||||
);
|
||||
});
|
||||
+82
-2
@@ -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'
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user