feat(git-sync): native GitmostDataSource + 'git-sync' provenance (Phase A.4a)
Native data plane for git-sync (plan §3, §8.1):
- provenance: widen actor to 'user'|'agent'|'git-sync' (jwt-payload,
auth-provenance decorator); PersistenceExtension resolves lastUpdatedSource
with precedence agent > git-sync > user, debounced history (like a human edit,
not the agent's immediate snapshot).
- GitmostDataSourceService implements @docmost/git-sync's GitSyncClient natively:
reads via PageRepo/SpaceRepo (listSpaceTree complete:true, getPageJson), writes
via PageService (create/removePage soft-delete/movePage with computed fractional
position/update-rename/restore) + the writeBody linchpin through collab
openDirectConnection('page.'+id, {actor:'git-sync'}) mirroring
collaboration.handler withYdocConnection 'replace'. bind({workspaceId,userId})
returns the context-bound client for the orchestrator.
- 10 unit/contract tests (mapping + soft-delete + move-position), tsc clean.
Known gap (closed in A.4b): PageService.create/update/movePage only branch on
actor==='agent'; git-sync provenance is already passed through so the row source
marker propagates once PageService honors 'git-sync'. Module/orchestrator/config
come next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
// Stub the collab util so importing the service does not drag in the
|
||||
// editor-ext -> @tiptap/react -> react-dom graph (unloadable under jest's node
|
||||
// env, same coupling noted in mcp.service.spec.ts). The captured transact
|
||||
// callback is never executed in these unit tests, so the stub extensions array
|
||||
// is sufficient; the real collab write path is exercised by integration tests.
|
||||
jest.mock('../../../collaboration/collaboration.util', () => ({
|
||||
tiptapExtensions: [],
|
||||
getPageId: (name: string) => name.replace(/^page\./, ''),
|
||||
}));
|
||||
// PageService is only ever a mocked dependency here; stub the editor-ext entry
|
||||
// it imports so loading its module does not pull in the React graph either.
|
||||
jest.mock('@docmost/editor-ext', () => ({
|
||||
markdownToHtml: jest.fn(),
|
||||
}));
|
||||
|
||||
import { GitmostDataSourceService } from './gitmost-datasource.service';
|
||||
|
||||
// Focused unit/contract test for the native GitSyncClient adapter (plan §3).
|
||||
// No DB, no real collab server: the repos/services/gateway are mocked and we
|
||||
// assert the mapping logic + the provenance/soft-delete/position contracts.
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
interface Mocks {
|
||||
pageRepo: {
|
||||
findById: AnyMock;
|
||||
getSpaceDescendants: AnyMock;
|
||||
restorePage: AnyMock;
|
||||
};
|
||||
spaceRepo: { findById: AnyMock };
|
||||
pageService: {
|
||||
create: AnyMock;
|
||||
update: AnyMock;
|
||||
movePage: AnyMock;
|
||||
removePage: AnyMock;
|
||||
};
|
||||
collabGateway: { openDirectConnection: AnyMock };
|
||||
// Minimal Kysely-ish chainable mock for the direct-query paths.
|
||||
db: any;
|
||||
// Captured collab connection (the fake conn the gateway returns).
|
||||
conn: {
|
||||
transact: AnyMock;
|
||||
disconnect: AnyMock;
|
||||
context?: any;
|
||||
capturedFn?: (doc: any) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function makeQueryBuilder(rows: any[]) {
|
||||
const qb: any = {};
|
||||
for (const m of ['select', 'where', 'orderBy', 'limit']) {
|
||||
qb[m] = jest.fn(() => qb);
|
||||
}
|
||||
qb.execute = jest.fn(async () => rows);
|
||||
qb.executeTakeFirst = jest.fn(async () => rows[0]);
|
||||
return qb;
|
||||
}
|
||||
|
||||
function build(rows: any[] = []): {
|
||||
service: GitmostDataSourceService;
|
||||
mocks: Mocks;
|
||||
} {
|
||||
const conn: Mocks['conn'] = {
|
||||
transact: jest.fn(async (fn: (doc: any) => void) => {
|
||||
conn.capturedFn = fn;
|
||||
}),
|
||||
disconnect: jest.fn(async () => undefined),
|
||||
};
|
||||
|
||||
const mocks: Mocks = {
|
||||
pageRepo: {
|
||||
findById: jest.fn(),
|
||||
getSpaceDescendants: jest.fn(),
|
||||
restorePage: jest.fn(async () => undefined),
|
||||
},
|
||||
spaceRepo: { findById: jest.fn(async () => ({ id: 'space-1' })) },
|
||||
pageService: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(async () => undefined),
|
||||
movePage: jest.fn(async () => undefined),
|
||||
removePage: jest.fn(async () => undefined),
|
||||
},
|
||||
collabGateway: {
|
||||
openDirectConnection: jest.fn(async (_name: string, ctx: any) => {
|
||||
conn.context = ctx;
|
||||
return conn;
|
||||
}),
|
||||
},
|
||||
db: {
|
||||
selectFrom: jest.fn(() => makeQueryBuilder(rows)),
|
||||
},
|
||||
conn,
|
||||
};
|
||||
|
||||
const service = new GitmostDataSourceService(
|
||||
mocks.pageRepo as any,
|
||||
mocks.spaceRepo as any,
|
||||
mocks.pageService as any,
|
||||
mocks.collabGateway as any,
|
||||
mocks.db as any,
|
||||
);
|
||||
|
||||
return { service, mocks };
|
||||
}
|
||||
|
||||
const CTX = { workspaceId: 'ws-1', userId: 'svc-user' };
|
||||
|
||||
describe('GitmostDataSourceService', () => {
|
||||
describe('listSpaceTree', () => {
|
||||
it('maps descendants to PageNode and is always complete:true', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.spaceRepo.findById.mockResolvedValue({ id: 'space-1' });
|
||||
mocks.pageRepo.getSpaceDescendants.mockResolvedValue([
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Root',
|
||||
parentPageId: null,
|
||||
position: 'a0',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
slugId: 's2',
|
||||
title: 'Child',
|
||||
parentPageId: 'p1',
|
||||
position: 'a1',
|
||||
},
|
||||
]);
|
||||
|
||||
const client = service.bind(CTX);
|
||||
const res = await client.listSpaceTree('space-1');
|
||||
|
||||
expect(res.complete).toBe(true);
|
||||
expect(mocks.pageRepo.getSpaceDescendants).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
{ includeContent: false },
|
||||
);
|
||||
expect(res.pages).toEqual([
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Root',
|
||||
parentPageId: null,
|
||||
hasChildren: true, // p2's parent is p1
|
||||
position: 'a0',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
slugId: 's2',
|
||||
title: 'Child',
|
||||
parentPageId: 'p1',
|
||||
hasChildren: false,
|
||||
position: 'a1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when the space is not found', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.spaceRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(service.bind(CTX).listSpaceTree('nope')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPageJson', () => {
|
||||
it('returns the engine page shape with ISO updatedAt + content', async () => {
|
||||
const { service, mocks } = build();
|
||||
const updatedAt = new Date('2026-06-20T10:00:00.000Z');
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Doc',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
updatedAt,
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
|
||||
const res = await service.bind(CTX).getPageJson('p1');
|
||||
expect(mocks.pageRepo.findById).toHaveBeenCalledWith('p1', {
|
||||
includeContent: true,
|
||||
});
|
||||
expect(res).toEqual({
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Doc',
|
||||
parentPageId: null,
|
||||
spaceId: 'space-1',
|
||||
updatedAt: '2026-06-20T10:00:00.000Z',
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPageMarkdown', () => {
|
||||
it('parses md, converts to ProseMirror, and writes body via collab', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
updatedAt: new Date('2026-06-20T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
const res = await service
|
||||
.bind(CTX)
|
||||
.importPageMarkdown('p1', '# Hello\n\nworld');
|
||||
|
||||
// writeBody opened a collab connection tagged git-sync + service user.
|
||||
expect(mocks.collabGateway.openDirectConnection).toHaveBeenCalledTimes(1);
|
||||
const [docName, ctx] = mocks.collabGateway.openDirectConnection.mock
|
||||
.calls[0];
|
||||
expect(docName).toBe('page.p1');
|
||||
expect(ctx.actor).toBe('git-sync');
|
||||
expect(ctx.user).toEqual({ id: 'svc-user' });
|
||||
|
||||
// transact ran and connection was disconnected (finally).
|
||||
expect(mocks.conn.transact).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.conn.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(typeof mocks.conn.capturedFn).toBe('function');
|
||||
|
||||
expect(res.updatedAt).toBe('2026-06-20T11:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPage', () => {
|
||||
it('creates the shell with git-sync provenance, writes body, returns id', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageService.create.mockResolvedValue({ id: 'new-id' });
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'new-id',
|
||||
updatedAt: new Date('2026-06-20T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
const res = await service
|
||||
.bind(CTX)
|
||||
.createPage('Title', 'body md', 'space-1', 'parent-1');
|
||||
|
||||
expect(mocks.pageService.create).toHaveBeenCalledWith(
|
||||
'svc-user',
|
||||
'ws-1',
|
||||
{ spaceId: 'space-1', title: 'Title', parentPageId: 'parent-1' },
|
||||
{ actor: 'git-sync', aiChatId: null },
|
||||
);
|
||||
expect(mocks.collabGateway.openDirectConnection).toHaveBeenCalledWith(
|
||||
'page.new-id',
|
||||
expect.objectContaining({ actor: 'git-sync' }),
|
||||
);
|
||||
expect(res).toEqual({
|
||||
data: { id: 'new-id' },
|
||||
updatedAt: '2026-06-20T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePage', () => {
|
||||
it('uses the soft-delete path (removePage), not a force delete', async () => {
|
||||
const { service, mocks } = build();
|
||||
await service.bind(CTX).deletePage('p1');
|
||||
expect(mocks.pageService.removePage).toHaveBeenCalledWith(
|
||||
'p1',
|
||||
'svc-user',
|
||||
'ws-1',
|
||||
);
|
||||
// No forceDelete on the service surface used here.
|
||||
expect((mocks.pageService as any).forceDelete).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePage', () => {
|
||||
it('computes a fractional position when none is supplied', async () => {
|
||||
// db query returns a last sibling at 'a0' -> jittered key after it.
|
||||
const { service, mocks } = build([{ position: 'a0' }]);
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
|
||||
await service.bind(CTX).movePage('p1', 'parent-1');
|
||||
|
||||
expect(mocks.pageService.movePage).toHaveBeenCalledTimes(1);
|
||||
const [dto, page, provenance] = mocks.pageService.movePage.mock.calls[0];
|
||||
expect(dto.pageId).toBe('p1');
|
||||
expect(dto.parentPageId).toBe('parent-1');
|
||||
expect(typeof dto.position).toBe('string');
|
||||
expect(dto.position.length).toBeGreaterThan(0);
|
||||
expect(page).toEqual({ id: 'p1', spaceId: 'space-1' });
|
||||
expect(provenance).toEqual({ actor: 'git-sync', aiChatId: null });
|
||||
});
|
||||
|
||||
it('passes through an explicit position unchanged', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageRepo.findById.mockResolvedValue({
|
||||
id: 'p1',
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
|
||||
await service.bind(CTX).movePage('p1', null, 'zz');
|
||||
const [dto] = mocks.pageService.movePage.mock.calls[0];
|
||||
expect(dto.position).toBe('zz');
|
||||
// db not consulted for a supplied position.
|
||||
expect(mocks.db.selectFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renamePage', () => {
|
||||
it('updates only the title with git-sync provenance', async () => {
|
||||
const { service, mocks } = build();
|
||||
mocks.pageRepo.findById.mockResolvedValue({ id: 'p1', title: 'old' });
|
||||
|
||||
await service.bind(CTX).renamePage('p1', 'new title');
|
||||
|
||||
const [page, dto, user, provenance] =
|
||||
mocks.pageService.update.mock.calls[0];
|
||||
expect(page).toEqual({ id: 'p1', title: 'old' });
|
||||
expect(dto.title).toBe('new title');
|
||||
expect(user).toEqual({ id: 'svc-user' });
|
||||
expect(provenance).toEqual({ actor: 'git-sync', aiChatId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('restorePage', () => {
|
||||
it('restores via the repo restore path scoped to the workspace', async () => {
|
||||
const { service, mocks } = build();
|
||||
await service.bind(CTX).restorePage('p1');
|
||||
expect(mocks.pageRepo.restorePage).toHaveBeenCalledWith('p1', 'ws-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user