Merge develop into feat/git-sync — unify converter on the branch (#293/#326 step 6a)
Per maintainer directive (#119 comment): land the canonical converter on the git-sync branch so sync is tested on the real format, NOT a dead legacy copy. #119 itself stays FROZEN (не вливается) — this only merges develop in. Resolutions (all git-sync converter conflicts → develop; engine kept as-is): - Dropped the branch's legacy `packages/git-sync/src/lib/*` converter — the converter now lives solely in `@docmost/prosemirror-markdown` (#293); the engine (pull/push/stabilize/index) only switches its imports to the package (no logic change, verified by diff). - Removed the branch's orphaned converter tests + fixtures under `packages/git-sync/test/` (their coverage moved to the package's own test suite on develop); git-sync/test now holds engine tests only. - .gitignore / Dockerfile / test.yml / AGENTS.md: unioned — build/ ignored for every package; Dockerfile COPYs both prosemirror-markdown/build (mcp+git-sync runtime) and git-sync/build (git-sync's runtime consumer lands on this branch); CI builds prosemirror-markdown before git-sync/mcp. - pnpm-lock.yaml regenerated for the merged workspace. Branch adaptations to canon (server-side tests only — converter untouched, per the guardrail that converter fixes go to the package on develop, fixtures-first): - git-sync-converter-gate.spec.ts: heading textAlign and image width/height now round-trip via the canon trailing-comment forms (#9 `<!--attrs {...}-->`, #4 `<!--img {...}-->`) instead of the old HTML-tag forms — expectations flipped to the real canon output. RESIDUAL: canon #4 does not yet carry image `align` (documented as a known divergence; fix belongs in the package on develop). - schema-attribute-contract.spec.ts: the schema mirror moved from `@docmost/git-sync/lib/docmost-schema` to `@docmost/prosemirror-markdown`; import + jest source-mapper updated. Verified: prosemirror-markdown/git-sync/mcp build clean; git-sync corpus green; server `tsc --noEmit` 0; gate + schema-attribute-contract specs 32/32.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { CommentRepo } from '../../src/database/repos/comment/comment.repo';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
buildTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
createPage,
|
||||
createUser,
|
||||
createComment,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* Real-DB coverage for CommentRepo.deleteCommentIfChildless (#338 F4/F6).
|
||||
*
|
||||
* This is the guard that keeps an ephemeral-suggestion hard-delete from
|
||||
* cascade-destroying a reply (`comments.parent_comment_id` is ON DELETE CASCADE).
|
||||
* The unit tests MOCK this method to 0/1, so only an int-spec actually exercises
|
||||
* the SQL — the FOR UPDATE lock-then-recheck transaction — against Postgres.
|
||||
*
|
||||
* The concurrency case is the whole point: a plain anti-join
|
||||
* `DELETE … WHERE NOT EXISTS(child)` passes (a) and (b) but SILENTLY loses a
|
||||
* reply that commits mid-operation under READ COMMITTED (EvalPlanQual does not
|
||||
* re-check a merely-locked row). Test (c) reproduces exactly that interleaving
|
||||
* and asserts the row + reply both survive.
|
||||
*/
|
||||
describe('CommentRepo.deleteCommentIfChildless [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: CommentRepo;
|
||||
let workspaceId: string;
|
||||
let spaceId: string;
|
||||
let pageId: string;
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
repo = new CommentRepo(db as any);
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, workspaceId)).id;
|
||||
pageId = (await createPage(db, { workspaceId, spaceId })).id;
|
||||
userId = (await createUser(db, workspaceId)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
async function rowExists(id: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.selectFrom('comments')
|
||||
.select('id')
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
function seedTopLevel() {
|
||||
return createComment(db, {
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
creatorId: userId,
|
||||
selection: 'old text',
|
||||
suggestedText: 'new text',
|
||||
});
|
||||
}
|
||||
|
||||
function seedReply(parentId: string) {
|
||||
return createComment(db, {
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
creatorId: userId,
|
||||
parentCommentId: parentId,
|
||||
});
|
||||
}
|
||||
|
||||
it('(a) childless top-level → returns 1 and the row is gone', async () => {
|
||||
const parent = await seedTopLevel();
|
||||
expect(await rowExists(parent.id)).toBe(true);
|
||||
|
||||
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect(await rowExists(parent.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('(b) top-level WITH a committed reply → returns 0, parent AND reply survive (gate blocks the cascade)', async () => {
|
||||
const parent = await seedTopLevel();
|
||||
const reply = await seedReply(parent.id);
|
||||
|
||||
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
expect(await rowExists(parent.id)).toBe(true);
|
||||
expect(await rowExists(reply.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('(c) reply COMMITS mid-operation (FOR UPDATE path) → returns 0, parent + reply survive; a blind anti-join would lose the reply', async () => {
|
||||
const parent = await seedTopLevel();
|
||||
|
||||
// Second connection holds an open transaction that inserts a reply (taking
|
||||
// FOR KEY SHARE on the parent via the FK) and does NOT commit until we open
|
||||
// the gate — reproducing the "reply not yet committed" window.
|
||||
const conn2 = buildTestDb();
|
||||
let openGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
});
|
||||
let replyId: string | undefined;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
try {
|
||||
const replyTx = conn2.transaction().execute(async (trx) => {
|
||||
const row = await trx
|
||||
.insertInto('comments')
|
||||
.values({
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
creatorId: userId,
|
||||
parentCommentId: parent.id,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
replyId = row.id as string;
|
||||
// Hold the FOR KEY SHARE lock on the parent until the gate opens.
|
||||
await gate;
|
||||
});
|
||||
|
||||
// Let the reply INSERT acquire its lock before the delete starts.
|
||||
await sleep(250);
|
||||
|
||||
// deleteCommentIfChildless does SELECT ... FOR UPDATE on the parent, which
|
||||
// conflicts with the reply's FOR KEY SHARE, so it BLOCKS here.
|
||||
const deletePromise = repo.deleteCommentIfChildless(parent.id);
|
||||
|
||||
// Give the delete time to reach (and block on) its FOR UPDATE, then let the
|
||||
// reply commit. The delete then wakes, re-checks under the lock, sees the
|
||||
// now-committed reply, and returns 0.
|
||||
await sleep(250);
|
||||
openGate();
|
||||
await replyTx;
|
||||
|
||||
const deleted = await deletePromise;
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
expect(await rowExists(parent.id)).toBe(true);
|
||||
expect(replyId).toBeDefined();
|
||||
expect(await rowExists(replyId!)).toBe(true);
|
||||
} finally {
|
||||
// Always release the gate (in case an assertion threw before openGate) and
|
||||
// close the extra connection so global-teardown can DROP the database.
|
||||
openGate();
|
||||
await conn2.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,62 @@ export async function createUser(
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
// The default group every workspace has; `groupUserRepo.addUserToDefaultGroup`
|
||||
// (invoked by acceptInvitation) looks it up by `isDefault = true`, so a
|
||||
// workspace under test must have exactly one for the accept path to complete.
|
||||
export async function createDefaultGroup(
|
||||
db: Kysely<any>,
|
||||
workspaceId: string,
|
||||
overrides: { name?: string } = {},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const suffix = shortId(id);
|
||||
const row = await db
|
||||
.insertInto('groups')
|
||||
.values({
|
||||
id,
|
||||
// name is unique per workspace + NOT NULL.
|
||||
name: overrides.name ?? `group-${suffix}`,
|
||||
isDefault: true,
|
||||
workspaceId,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
// A pending workspace invitation. `role`/`token` are NOT NULL; `groupIds` is a
|
||||
// nullable uuid[] and `invitedById` a nullable FK to users. Returns the fields a
|
||||
// spec needs to drive acceptInvitation (id + token + the invited email).
|
||||
export async function createInvitation(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
workspaceId: string;
|
||||
email: string;
|
||||
invitedById?: string | null;
|
||||
role?: string;
|
||||
token?: string;
|
||||
groupIds?: string[] | null;
|
||||
},
|
||||
): Promise<{ id: string; token: string; email: string }> {
|
||||
const id = randomUUID();
|
||||
const token = args.token ?? `tok-${shortId(id)}`;
|
||||
const row = await db
|
||||
.insertInto('workspaceInvitations')
|
||||
.values({
|
||||
id,
|
||||
email: args.email,
|
||||
role: args.role ?? 'member',
|
||||
token,
|
||||
groupIds: (args.groupIds ?? null) as any,
|
||||
invitedById: args.invitedById ?? null,
|
||||
workspaceId: args.workspaceId,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string, token, email: args.email };
|
||||
}
|
||||
|
||||
export async function createSpace(
|
||||
db: Kysely<any>,
|
||||
workspaceId: string,
|
||||
@@ -174,6 +230,40 @@ export async function createPage(
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createComment(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
workspaceId: string;
|
||||
spaceId: string;
|
||||
pageId: string;
|
||||
creatorId?: string | null;
|
||||
parentCommentId?: string | null;
|
||||
content?: unknown;
|
||||
selection?: string | null;
|
||||
suggestedText?: string | null;
|
||||
type?: string | null;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('comments')
|
||||
.values({
|
||||
id,
|
||||
workspaceId: args.workspaceId,
|
||||
spaceId: args.spaceId,
|
||||
pageId: args.pageId,
|
||||
creatorId: args.creatorId ?? null,
|
||||
parentCommentId: args.parentCommentId ?? null,
|
||||
content: (args.content ?? null) as any,
|
||||
selection: args.selection ?? null,
|
||||
suggestedText: args.suggestedText ?? null,
|
||||
type: args.type ?? 'page',
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { WorkspaceInvitationService } from 'src/core/workspace/services/workspace-invitation.service';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createUser,
|
||||
createDefaultGroup,
|
||||
createInvitation,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* acceptInvitation atomicity (issue #324, tail of #244).
|
||||
*
|
||||
* acceptInvitation() reads the invitation OUTSIDE the transaction, then inside a
|
||||
* single tx: inserts the invited user, adds them to the default group, and
|
||||
* deletes the invitation. Two accepts of the SAME invitation therefore race to
|
||||
* insert a user with the same (email, workspaceId) — which the
|
||||
* `users_email_workspace_id_unique` constraint forbids. The service catches that
|
||||
* violation and reports "Invitation already accepted".
|
||||
*
|
||||
* These specs pin the INVARIANT that path protects: no matter how many times the
|
||||
* invitation is accepted (concurrently or repeatedly), the workspace ends up
|
||||
* with exactly ONE membership for the invited email and the invitation is
|
||||
* consumed exactly once — never a duplicate user and never a half-applied state.
|
||||
*
|
||||
* The service is wired with the REAL repos (UserRepo / GroupRepo / GroupUserRepo)
|
||||
* against the test Kysely; only the peripheral collaborators that acceptInvitation
|
||||
* touches AFTER the transaction (mail, session token, billing, audit, env) are
|
||||
* stubbed, so the exercised DB write path is the production one.
|
||||
*/
|
||||
describe('WorkspaceInvitationService.acceptInvitation atomicity [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let service: WorkspaceInvitationService;
|
||||
|
||||
// Count the memberships (user rows) for an email within a workspace — the
|
||||
// quantity the atomicity guarantee is about.
|
||||
async function membershipCount(
|
||||
workspaceId: string,
|
||||
email: string,
|
||||
): Promise<number> {
|
||||
const rows = await db
|
||||
.selectFrom('users')
|
||||
.select('id')
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('email', '=', email.toLowerCase())
|
||||
.execute();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function invitationExists(invitationId: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.select('id')
|
||||
.where('id', '=', invitationId)
|
||||
.executeTakeFirst();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
db = getTestDb();
|
||||
|
||||
const userRepo = new UserRepo(db as any);
|
||||
const groupRepo = new GroupRepo(db as any);
|
||||
const groupUserRepo = new GroupUserRepo(db as any, groupRepo, userRepo);
|
||||
|
||||
// Collaborators used only on the post-commit success tail; safe to stub.
|
||||
const mailService = { sendToQueue: jest.fn().mockResolvedValue(undefined) };
|
||||
const domainService = {} as any;
|
||||
const tokenService = {} as any;
|
||||
const sessionService = {
|
||||
createSessionAndToken: jest.fn().mockResolvedValue('test-auth-token'),
|
||||
};
|
||||
const billingQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const environmentService = { isCloud: () => false };
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
service = new WorkspaceInvitationService(
|
||||
userRepo,
|
||||
groupUserRepo,
|
||||
mailService as any,
|
||||
domainService,
|
||||
tokenService,
|
||||
sessionService as any,
|
||||
db as any,
|
||||
billingQueue as any,
|
||||
environmentService as any,
|
||||
auditService as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
// A workspace with its default group, an inviter, and a pending invitation.
|
||||
async function seedInvite(): Promise<{
|
||||
workspace: Workspace;
|
||||
invitationId: string;
|
||||
token: string;
|
||||
email: string;
|
||||
}> {
|
||||
const { id: workspaceId } = await createWorkspace(db);
|
||||
await createDefaultGroup(db, workspaceId);
|
||||
const inviter = await createUser(db, workspaceId);
|
||||
// Distinct address per invite so specs never collide across the suite.
|
||||
const email = `invitee-${workspaceId.slice(0, 8)}@example.test`;
|
||||
const invite = await createInvitation(db, {
|
||||
workspaceId,
|
||||
email,
|
||||
invitedById: inviter.id,
|
||||
});
|
||||
|
||||
// acceptInvitation only reads id/hostname/enforceSso/emailDomains/enforceMfa
|
||||
// off the workspace; a minimal plain object is sufficient.
|
||||
const workspace = {
|
||||
id: workspaceId,
|
||||
hostname: `host-${workspaceId.slice(0, 8)}`,
|
||||
enforceSso: false,
|
||||
enforceMfa: false,
|
||||
emailDomains: [] as string[],
|
||||
} as unknown as Workspace;
|
||||
|
||||
return { workspace, invitationId: invite.id, token: invite.token, email };
|
||||
}
|
||||
|
||||
it('concurrent accepts create a single membership and consume the invitation once', async () => {
|
||||
const { workspace, invitationId, token, email } = await seedInvite();
|
||||
|
||||
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||
|
||||
// Fire two accepts of the SAME invitation at once. They race to insert the
|
||||
// same (email, workspaceId); the unique constraint lets exactly one win.
|
||||
const results = await Promise.allSettled([
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
const rejected = results.filter(
|
||||
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||
);
|
||||
|
||||
// Exactly one accept succeeds; the other is rejected.
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
|
||||
// The loser fails via the caught unique-constraint path with the specific
|
||||
// "already accepted" message — not a half-state / generic failure.
|
||||
expect(rejected[0].reason).toBeInstanceOf(BadRequestException);
|
||||
expect(rejected[0].reason.message).toBe('Invitation already accepted');
|
||||
|
||||
// Invariant: exactly one membership, and the invitation is gone.
|
||||
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||
expect(await invitationExists(invitationId)).toBe(false);
|
||||
});
|
||||
|
||||
it('a repeated (sequential) accept does not create a duplicate membership', async () => {
|
||||
const { workspace, invitationId, token, email } = await seedInvite();
|
||||
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||
|
||||
// First accept succeeds and returns an auth token.
|
||||
const first = await service.acceptInvitation({ ...dto }, workspace);
|
||||
expect(first?.authToken).toBe('test-auth-token');
|
||||
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||
expect(await invitationExists(invitationId)).toBe(false);
|
||||
|
||||
// Re-accepting the (now consumed) invitation must be rejected and must NOT
|
||||
// add a second membership. The invitation row is gone, so this hits the
|
||||
// "Invitation not found" guard rather than the unique-constraint path.
|
||||
await expect(
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||
});
|
||||
|
||||
it('the single created membership is added to the default group (no partial state)', async () => {
|
||||
const { workspace, invitationId, token, email } = await seedInvite();
|
||||
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||
|
||||
await Promise.allSettled([
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
service.acceptInvitation({ ...dto }, workspace),
|
||||
]);
|
||||
|
||||
// Resolve the one surviving user and assert the whole tx applied: they exist
|
||||
// AND are in the workspace default group (the mid-transaction step), proving
|
||||
// the winning accept committed as a whole rather than leaving a torn state.
|
||||
const user = await db
|
||||
.selectFrom('users')
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('email', '=', email.toLowerCase())
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const defaultGroup = await db
|
||||
.selectFrom('groups')
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('isDefault', '=', true)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const membership = await db
|
||||
.selectFrom('groupUsers')
|
||||
.select(['userId'])
|
||||
.where('groupId', '=', defaultGroup.id)
|
||||
.where('userId', '=', user.id)
|
||||
.execute();
|
||||
|
||||
expect(membership).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user