diff --git a/apps/server/package.json b/apps/server/package.json index 6ee1931b..f090449d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,6 +41,7 @@ "@aws-sdk/s3-request-presigner": "3.1050.0", "@azure/storage-blob": "12.31.0", "@clickhouse/client": "^1.18.2", + "@docmost/git-sync": "workspace:*", "@docmost/mcp": "workspace:*", "@docmost/pdf-inspector": "1.9.6", "@fastify/cookie": "^11.0.2", diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index dd462877..adb77722 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -50,8 +50,13 @@ import { TransclusionService } from '../../core/page/transclusion/transclusion.s export function resolveSource( stickyTouched: boolean, contextActor?: string, -): 'agent' | 'user' { - return stickyTouched || contextActor === 'agent' ? 'agent' : 'user'; +): 'agent' | 'git-sync' | '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'; } /** @@ -175,6 +180,9 @@ export class PersistenceExtension implements Extension { // 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 // 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( this.consumeAgentTouched(documentName), context?.actor, diff --git a/apps/server/src/common/decorators/auth-provenance.decorator.ts b/apps/server/src/common/decorators/auth-provenance.decorator.ts index c0c67328..adfc0171 100644 --- a/apps/server/src/common/decorators/auth-provenance.decorator.ts +++ b/apps/server/src/common/decorators/auth-provenance.decorator.ts @@ -8,7 +8,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; * cannot fake an 'agent' marker. */ export interface AuthProvenanceData { - actor: 'user' | 'agent'; + // 'git-sync' is set by the in-process git-sync data plane (plan §8.1) when it + // drives PageService writes; it never originates from a request token. + actor: 'user' | 'agent' | 'git-sync'; aiChatId: string | null; } diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index b4a41dd7..11c814a9 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -18,8 +18,10 @@ export type JwtPayload = { // normal user token (treated as 'user'); set only when the internal agent // mints a provenance access token so REST writes (create/rename/move page, // comment create/resolve) record a non-spoofable 'agent' marker (§6.5 / §15 - // C3 / §14 N2). - actor?: 'user' | 'agent'; + // C3 / §14 N2). '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. + actor?: 'user' | 'agent' | 'git-sync'; aiChatId?: string; }; @@ -29,8 +31,9 @@ export type JwtCollabPayload = { type: 'collab'; // Optional agent-edit provenance, signed into the collab token. Absent for // the human collab path (treated as 'user'); set only when the internal agent - // mints a provenance collab token (§6.6 / §15 C2). - actor?: 'user' | 'agent'; + // mints a provenance collab token (§6.6 / §15 C2). 'git-sync' is accepted for + // type-compatibility with the in-process git-sync write path (plan §8.1). + actor?: 'user' | 'agent' | 'git-sync'; aiChatId?: string; }; diff --git a/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.spec.ts b/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.spec.ts new file mode 100644 index 00000000..5e3d103a --- /dev/null +++ b/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts b/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts new file mode 100644 index 00000000..baf3517e --- /dev/null +++ b/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts @@ -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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } + } +}