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:
@@ -41,6 +41,7 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||||
"@azure/storage-blob": "12.31.0",
|
"@azure/storage-blob": "12.31.0",
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
|
"@docmost/git-sync": "workspace:*",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
|||||||
@@ -52,7 +52,12 @@ export function resolveSource(
|
|||||||
stickyTouched: boolean,
|
stickyTouched: boolean,
|
||||||
contextActor?: string,
|
contextActor?: string,
|
||||||
): ProvenanceSource {
|
): ProvenanceSource {
|
||||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
// Precedence: agent > git-sync > user. The sticky agent marker wins so a
|
||||||
|
// window that mixed an agent edit stays tagged 'agent'; otherwise a native
|
||||||
|
// git-sync write (plan §8.1) tags 'git-sync'; a plain human edit stays 'user'.
|
||||||
|
if (stickyTouched || contextActor === 'agent') return 'agent';
|
||||||
|
if (contextActor === 'git-sync') return 'git-sync';
|
||||||
|
return 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +181,9 @@ export class PersistenceExtension implements Extension {
|
|||||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||||
// if the current writer is the agent (covers a store with no prior onChange
|
// if the current writer is the agent (covers a store with no prior onChange
|
||||||
// agent event in the same window). §15 H2.
|
// agent event in the same window). §15 H2.
|
||||||
|
// Provenance precedence: agent > git-sync > user (see resolveSource). A
|
||||||
|
// 'git-sync' store is NOT given an immediate history snapshot — it is
|
||||||
|
// debounced like a human edit (git-sync writes are full-body replaces).
|
||||||
const lastUpdatedSource = resolveSource(
|
const lastUpdatedSource = resolveSource(
|
||||||
this.consumeAgentTouched(documentName),
|
this.consumeAgentTouched(documentName),
|
||||||
context?.actor,
|
context?.actor,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { ProvenanceSource } from '../../core/auth/dto/jwt-payload';
|
|||||||
* cannot fake an 'agent' marker.
|
* cannot fake an 'agent' marker.
|
||||||
*/
|
*/
|
||||||
export interface AuthProvenanceData {
|
export interface AuthProvenanceData {
|
||||||
|
// ProvenanceSource includes 'git-sync' — set by the in-process git-sync data
|
||||||
|
// plane (plan §8.1) when it drives PageService writes; never from a request token.
|
||||||
actor: ProvenanceSource;
|
actor: ProvenanceSource;
|
||||||
aiChatId: string | null;
|
aiChatId: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable.
|
* from the SIGNED token claim (never a request body), so 'agent' is unspoofable.
|
||||||
* Single source of truth so a typo like 'agnet' can't slip through as a bare
|
* Single source of truth so a typo like 'agnet' can't slip through as a bare
|
||||||
* string (#143 review). Distinct from `ActorType` (auth principal kind).
|
* string (#143 review). Distinct from `ActorType` (auth principal kind).
|
||||||
|
*
|
||||||
|
* 'git-sync' marks writes made by the git-sync data plane (plan §8.1). It NEVER
|
||||||
|
* travels in a user-facing token; it is set in-process on the collab connection
|
||||||
|
* context by the native datasource, so it cannot be spoofed from a request.
|
||||||
*/
|
*/
|
||||||
export type ProvenanceSource = 'user' | 'agent';
|
export type ProvenanceSource = 'user' | 'agent' | 'git-sync';
|
||||||
|
|
||||||
export enum JwtType {
|
export enum JwtType {
|
||||||
ACCESS = 'access',
|
ACCESS = 'access',
|
||||||
@@ -26,7 +30,8 @@ export type JwtPayload = {
|
|||||||
// normal user token (treated as 'user'); set only when the internal agent
|
// normal user token (treated as 'user'); set only when the internal agent
|
||||||
// mints a provenance access token so REST writes (create/rename/move page,
|
// mints a provenance access token so REST writes (create/rename/move page,
|
||||||
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
// comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15
|
||||||
// C3 / §14 N2).
|
// C3 / §14 N2). (git-sync writes use the in-process actor, not a token — see
|
||||||
|
// the ProvenanceSource note.)
|
||||||
actor?: ProvenanceSource;
|
actor?: ProvenanceSource;
|
||||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||||
// an 'agent' actor with a null aiChatId.
|
// an 'agent' actor with a null aiChatId.
|
||||||
@@ -39,7 +44,8 @@ export type JwtCollabPayload = {
|
|||||||
type: 'collab';
|
type: 'collab';
|
||||||
// Optional agent-edit provenance, signed into the collab token. Absent for
|
// Optional agent-edit provenance, signed into the collab token. Absent for
|
||||||
// the human collab path (treated as 'user'); set only when the internal agent
|
// the human collab path (treated as 'user'); set only when the internal agent
|
||||||
// mints a provenance collab token (§6.6 / §15 C2).
|
// mints a provenance collab token (§6.6 / §15 C2). 'git-sync' (in ProvenanceSource)
|
||||||
|
// is accepted for type-compatibility with the in-process git-sync write path.
|
||||||
actor?: ProvenanceSource;
|
actor?: ProvenanceSource;
|
||||||
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
// Nullable: an external MCP agent has no internal ai_chats row, so it carries
|
||||||
// an 'agent' actor with a null aiChatId.
|
// an 'agent' actor with a null aiChatId.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
|
import {
|
||||||
|
type GitSyncClient,
|
||||||
|
type GitSyncPageNodeLite,
|
||||||
|
parseDocmostMarkdown,
|
||||||
|
markdownToProseMirror,
|
||||||
|
} from '@docmost/git-sync';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { PageService } from '../../../core/page/services/page.service';
|
||||||
|
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||||
|
import { tiptapExtensions } from '../../../collaboration/collaboration.util';
|
||||||
|
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The acting context the orchestrator binds the datasource to. The datasource is
|
||||||
|
* NOT a fixed-identity singleton: it operates on behalf of a (workspaceId,
|
||||||
|
* userId) pair the orchestrator supplies per space (plan §3.2). `userId` is the
|
||||||
|
* git-sync service user — it stays the responsible author (creatorId /
|
||||||
|
* lastUpdatedById) while the `'git-sync'` actor marks provenance (plan §8.1).
|
||||||
|
*/
|
||||||
|
export interface GitSyncBindContext {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The git-sync provenance carried into PageService writes. PageService stamps
|
||||||
|
* `lastUpdatedSource = 'agent'` only when `provenance.actor === 'agent'`; for any
|
||||||
|
* other actor it leaves the column at its default ('user'). So create/move/rename
|
||||||
|
* through PageService DO NOT yet stamp 'git-sync' on the page row — see the note
|
||||||
|
* in the report. Body writes (writeBody, §3.3) DO stamp 'git-sync' because the
|
||||||
|
* collab context's `actor: 'git-sync'` flows into PersistenceExtension. We pass a
|
||||||
|
* 'git-sync' provenance anyway so that when PageService is extended to honor it,
|
||||||
|
* the marker propagates without touching the datasource.
|
||||||
|
*/
|
||||||
|
const GIT_SYNC_PROVENANCE: AuthProvenanceData = {
|
||||||
|
actor: 'git-sync',
|
||||||
|
aiChatId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native, in-process implementation of the engine's `GitSyncClient` seam
|
||||||
|
* (plan §3). Reads go through repositories (PageRepo/SpaceRepo); body writes go
|
||||||
|
* through collab `openDirectConnection` (§3.3); structural mutations
|
||||||
|
* (create/move/delete/rename) go through PageService.
|
||||||
|
*
|
||||||
|
* Shape: this is an `@Injectable()` holding the repos/services. The orchestrator
|
||||||
|
* calls `bind({ workspaceId, userId })` to obtain a `GitSyncClient` bound to that
|
||||||
|
* acting context. The bound object is a thin closure over `this` — no per-call
|
||||||
|
* identity plumbing leaks into the engine.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GitmostDataSourceService {
|
||||||
|
private readonly logger = new Logger(GitmostDataSourceService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly spaceRepo: SpaceRepo,
|
||||||
|
private readonly pageService: PageService,
|
||||||
|
private readonly collabGateway: CollaborationGateway,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind the datasource to an acting (workspaceId, userId) context and return a
|
||||||
|
* `GitSyncClient` the engine can consume directly.
|
||||||
|
*/
|
||||||
|
bind(ctx: GitSyncBindContext): GitSyncClient {
|
||||||
|
return {
|
||||||
|
listSpaceTree: (spaceId, rootPageId) =>
|
||||||
|
this.listSpaceTree(ctx, spaceId, rootPageId),
|
||||||
|
getPageJson: (pageId) => this.getPageJson(ctx, pageId),
|
||||||
|
importPageMarkdown: (pageId, fullMarkdown) =>
|
||||||
|
this.importPageMarkdown(ctx, pageId, fullMarkdown),
|
||||||
|
createPage: (title, content, spaceId, parentPageId) =>
|
||||||
|
this.createPage(ctx, title, content, spaceId, parentPageId),
|
||||||
|
deletePage: (pageId) => this.deletePage(ctx, pageId),
|
||||||
|
movePage: (pageId, parentPageId, position) =>
|
||||||
|
this.movePage(pageId, parentPageId, position),
|
||||||
|
renamePage: (pageId, title) => this.renamePage(ctx, pageId, title),
|
||||||
|
listRecentSince: (spaceId, sinceIso, hardPageCap) =>
|
||||||
|
this.listRecentSince(spaceId, sinceIso, hardPageCap),
|
||||||
|
listTrash: (spaceId) => this.listTrash(spaceId),
|
||||||
|
restorePage: (pageId) => this.restorePage(ctx, pageId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reads (pull) ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full page tree of a space mapped to the engine's `PageNode` shape. We read
|
||||||
|
* the DB directly, so `complete` is ALWAYS `true` — the incomplete-fetch
|
||||||
|
* suppression (SPEC §8) never fires natively (plan §3.2).
|
||||||
|
*/
|
||||||
|
private async listSpaceTree(
|
||||||
|
ctx: GitSyncBindContext,
|
||||||
|
spaceId: string,
|
||||||
|
_rootPageId?: string,
|
||||||
|
): Promise<{ pages: GitSyncPageNodeLite[]; complete: boolean }> {
|
||||||
|
const space = await this.spaceRepo.findById(spaceId, ctx.workspaceId);
|
||||||
|
if (!space) {
|
||||||
|
throw new NotFoundException(`Space ${spaceId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.pageRepo.getSpaceDescendants(space.id, {
|
||||||
|
includeContent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `getSpaceDescendants` does not select `hasChildren`; derive it from the
|
||||||
|
// parent links present in the same result set.
|
||||||
|
const parentIds = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.parentPageId) parentIds.add(row.parentPageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: GitSyncPageNodeLite[] = rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
slugId: row.slugId,
|
||||||
|
title: row.title,
|
||||||
|
parentPageId: row.parentPageId ?? null,
|
||||||
|
hasChildren: parentIds.has(row.id),
|
||||||
|
position: row.position,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { pages, complete: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page WITH its ProseMirror body content (editor-ext schema). `updatedAt`
|
||||||
|
* is serialized to an ISO string for the loop-guard.
|
||||||
|
*/
|
||||||
|
private async getPageJson(
|
||||||
|
ctx: GitSyncBindContext,
|
||||||
|
pageId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string;
|
||||||
|
parentPageId: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
updatedAt: string;
|
||||||
|
content: unknown;
|
||||||
|
}> {
|
||||||
|
const page = await this.pageRepo.findById(pageId, { includeContent: true });
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException(`Page ${pageId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
slugId: page.slugId,
|
||||||
|
title: page.title,
|
||||||
|
parentPageId: page.parentPageId ?? null,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
updatedAt: new Date(page.updatedAt).toISOString(),
|
||||||
|
content: page.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes (push) --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a page's body from a self-contained markdown file: parse the meta+
|
||||||
|
* body envelope, convert the body to ProseMirror, then write it through collab
|
||||||
|
* (§3.3). Returns the fresh page's `updatedAt` for the loop-guard.
|
||||||
|
*/
|
||||||
|
private async importPageMarkdown(
|
||||||
|
ctx: GitSyncBindContext,
|
||||||
|
pageId: string,
|
||||||
|
fullMarkdown: string,
|
||||||
|
): Promise<{ updatedAt?: string }> {
|
||||||
|
const { body } = parseDocmostMarkdown(fullMarkdown);
|
||||||
|
const doc = await markdownToProseMirror(body);
|
||||||
|
await this.writeBody(pageId, doc, ctx.userId);
|
||||||
|
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
return {
|
||||||
|
updatedAt: page ? new Date(page.updatedAt).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a page shell via PageService, then write its body through collab.
|
||||||
|
* Returns the assigned id (`data.id`) + the page's `updatedAt`.
|
||||||
|
*/
|
||||||
|
private async createPage(
|
||||||
|
ctx: GitSyncBindContext,
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
spaceId: string,
|
||||||
|
parentPageId?: string,
|
||||||
|
): Promise<{ data: { id: string }; updatedAt?: string }> {
|
||||||
|
const page = await this.pageService.create(
|
||||||
|
ctx.userId,
|
||||||
|
ctx.workspaceId,
|
||||||
|
{ spaceId, title, parentPageId },
|
||||||
|
GIT_SYNC_PROVENANCE,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The shell is created without body; push the markdown body through collab.
|
||||||
|
const { body } = parseDocmostMarkdown(content);
|
||||||
|
const doc = await markdownToProseMirror(body);
|
||||||
|
await this.writeBody(page.id, doc, ctx.userId);
|
||||||
|
|
||||||
|
const fresh = await this.pageRepo.findById(page.id);
|
||||||
|
return {
|
||||||
|
data: { id: page.id },
|
||||||
|
updatedAt: fresh ? new Date(fresh.updatedAt).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete the page to Trash (reversible). NOT a force delete — `restorePage`
|
||||||
|
* can bring it back.
|
||||||
|
*/
|
||||||
|
private async deletePage(
|
||||||
|
ctx: GitSyncBindContext,
|
||||||
|
pageId: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
await this.pageService.removePage(pageId, ctx.userId, ctx.workspaceId);
|
||||||
|
return { id: pageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reparent a page. Docmost-move REQUIRES a fractional-index `position`; when the
|
||||||
|
* engine omits it, compute a key after the destination's last sibling (plan
|
||||||
|
* §3.2 / §14.4).
|
||||||
|
*/
|
||||||
|
private async movePage(
|
||||||
|
pageId: string,
|
||||||
|
parentPageId: string | null,
|
||||||
|
position?: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException(`Page ${pageId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPosition =
|
||||||
|
position ?? (await this.computeMovePosition(page.spaceId, parentPageId));
|
||||||
|
|
||||||
|
await this.pageService.movePage(
|
||||||
|
{ pageId, parentPageId: parentPageId ?? null, position: resolvedPosition },
|
||||||
|
page,
|
||||||
|
GIT_SYNC_PROVENANCE,
|
||||||
|
);
|
||||||
|
return { id: pageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a fractional-index position AFTER the last sibling under
|
||||||
|
* `parentPageId` (root pages when null) in the space, ordered by `position`
|
||||||
|
* with the "C" collation Docmost uses (plan §14.4). Falls back to a fresh key
|
||||||
|
* when there are no siblings.
|
||||||
|
*/
|
||||||
|
private async computeMovePosition(
|
||||||
|
spaceId: string,
|
||||||
|
parentPageId: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
let query = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['position'])
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.orderBy('position', (ob) => ob.collate('C').desc())
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
query = parentPageId
|
||||||
|
? query.where('parentPageId', '=', parentPageId)
|
||||||
|
: query.where('parentPageId', 'is', null);
|
||||||
|
|
||||||
|
const lastSibling = await query.executeTakeFirst();
|
||||||
|
return generateJitteredKeyBetween(lastSibling?.position ?? null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change a page's title only (no body touch). */
|
||||||
|
private async renamePage(
|
||||||
|
ctx: GitSyncBindContext,
|
||||||
|
pageId: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException(`Page ${pageId} not found`);
|
||||||
|
}
|
||||||
|
// PageService.update takes a User; the git-sync service user is the
|
||||||
|
// responsible author. Only the id is read off it for lastUpdatedById.
|
||||||
|
// `pageId` satisfies the UpdatePageDto type; PageService.update reads the
|
||||||
|
// page id off `page`, not the DTO. Only `title` is applied here.
|
||||||
|
await this.pageService.update(
|
||||||
|
page,
|
||||||
|
{ pageId, title },
|
||||||
|
{ id: ctx.userId } as any,
|
||||||
|
GIT_SYNC_PROVENANCE,
|
||||||
|
);
|
||||||
|
return { id: pageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- continuous (phase B+) ------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages in the space updated since `sinceIso` (poll-safety reconciliation,
|
||||||
|
* SPEC §8). `spaceId` undefined widens to all spaces; `hardPageCap` bounds the
|
||||||
|
* result. Reads the DB directly (no cursor pagination needed here).
|
||||||
|
*/
|
||||||
|
private async listRecentSince(
|
||||||
|
spaceId: string | undefined,
|
||||||
|
sinceIso: string | null,
|
||||||
|
hardPageCap?: number,
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
let query = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'parentPageId',
|
||||||
|
'spaceId',
|
||||||
|
'updatedAt',
|
||||||
|
])
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.orderBy('updatedAt', 'desc');
|
||||||
|
|
||||||
|
if (spaceId) query = query.where('spaceId', '=', spaceId);
|
||||||
|
if (sinceIso) query = query.where('updatedAt', '>', new Date(sinceIso));
|
||||||
|
if (hardPageCap) query = query.limit(hardPageCap);
|
||||||
|
|
||||||
|
const rows = await query.execute();
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-deleted (trashed) pages for the space (deletion detection). */
|
||||||
|
private async listTrash(spaceId: string): Promise<unknown[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'slugId', 'title', 'parentPageId', 'spaceId', 'deletedAt'])
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.where('deletedAt', 'is not', null)
|
||||||
|
.orderBy('deletedAt', 'desc')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
deletedAt: row.deletedAt ? new Date(row.deletedAt).toISOString() : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore a soft-deleted page from Trash. */
|
||||||
|
private async restorePage(
|
||||||
|
ctx: GitSyncBindContext,
|
||||||
|
pageId: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
await this.pageRepo.restorePage(pageId, ctx.workspaceId);
|
||||||
|
return { id: pageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- linchpin: native body write (§3.3) -----------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process body write — no loopback websocket, no service-user token. Mirrors
|
||||||
|
* the collab handler's 'replace' operation exactly: open a direct connection,
|
||||||
|
* drop the existing fragment, apply the converted doc, then disconnect.
|
||||||
|
*
|
||||||
|
* The `{ actor: 'git-sync', user: { id: userId } }` context flows into
|
||||||
|
* PersistenceExtension.onStoreDocument, which persists ydoc+content+textContent,
|
||||||
|
* stamps `lastUpdatedSource = 'git-sync'`, and broadcasts `page.updated`. The
|
||||||
|
* service user (`user.id`) stays the responsible `lastUpdatedById`; the actor
|
||||||
|
* marks provenance (plan §8.1).
|
||||||
|
*/
|
||||||
|
private async writeBody(
|
||||||
|
pageId: string,
|
||||||
|
prosemirrorJson: unknown,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = await this.collabGateway.openDirectConnection(`page.${pageId}`, {
|
||||||
|
actor: 'git-sync',
|
||||||
|
// PersistenceExtension reads `context.user.id` for lastUpdatedById, so the
|
||||||
|
// service user is required on the context (unlike the bare `{ actor }`
|
||||||
|
// sketch in the plan).
|
||||||
|
user: { id: userId },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await conn.transact((doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
if (fragment.length > 0) fragment.delete(0, fragment.length);
|
||||||
|
const next = TiptapTransformer.toYdoc(
|
||||||
|
prosemirrorJson,
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(next));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user