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 index af8ec9c0..5f2e5e24 100644 --- 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 @@ -418,6 +418,45 @@ describe('GitmostDataSourceService', () => { }); }); + // Bug C9-D1: a vault file with a malformed (non-UUID) `gitmost_id` frontmatter + // makes the id reach a Postgres `uuid` predicate, which throws error code + // '22P02'. Left unhandled the push apply records it as a per-cycle failure that + // never clears -> the whole space's sync loops forever. The bind() seam wraps the + // id-scoped writes so exactly that error is swallowed as an inert no-op. + describe('malformed-id guard (bug C9-D1: non-UUID gitmost_id must not wedge sync)', () => { + const pgInvalidUuid = Object.assign( + new Error('invalid input syntax for type uuid: "not-a-uuid"'), + { code: '22P02' }, + ); + + it('importPageMarkdown swallows a 22P02 and does NOT write the body', async () => { + const { service, mocks } = build(); + mocks.pageRepo.findById.mockRejectedValue(pgInvalidUuid); + const res = await service + .bind(CTX) + .importPageMarkdown('not-a-uuid', '# x'); + expect(res).toEqual({}); // inert no-op, no throw + expect(mocks.collabGateway.writePageBody).not.toHaveBeenCalled(); + }); + + it('deletePage swallows the 22P02 thrown by the uuid predicate (no wedge)', async () => { + const { service, mocks } = build(); + // The malformed id reaches removePage's `uuid` predicate, which throws 22P02. + mocks.pageService.removePage.mockRejectedValue(pgInvalidUuid); + await expect( + service.bind(CTX).deletePage('not-a-uuid'), + ).resolves.toBeUndefined(); + }); + + it('re-throws a NON-22P02 error (does not mask real failures)', async () => { + const { service, mocks } = build(); + mocks.pageRepo.findById.mockRejectedValue(new Error('db down')); + await expect( + service.bind(CTX).importPageMarkdown('not-a-uuid', '# x'), + ).rejects.toThrow('db down'); + }); + }); + describe('createPage', () => { it('creates the shell with git-sync provenance, writes body, returns id', async () => { const { service, mocks } = build(); 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 index f7c8eb32..f9e9f001 100644 --- a/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts +++ b/apps/server/src/integrations/git-sync/services/gitmost-datasource.service.ts @@ -78,21 +78,72 @@ export class GitmostDataSourceService { listSpaceTree: (spaceId, rootPageId) => this.listSpaceTree(ctx, spaceId, rootPageId), getPageJson: (pageId) => this.getPageJson(ctx, pageId), + // The id-scoped WRITE ops are wrapped so a malformed (non-UUID) `pageId` + // from a broken vault `gitmost_id` frontmatter cannot wedge the space's sync + // loop (bug C9-D1) — see `skipIfMalformedId`. importPageMarkdown: (pageId, fullMarkdown, baseMarkdown) => - this.importPageMarkdown(ctx, pageId, fullMarkdown, baseMarkdown), + this.skipIfMalformedId( + 'import', + pageId, + ctx, + () => this.importPageMarkdown(ctx, pageId, fullMarkdown, baseMarkdown), + {}, + ), createPage: (title, content, spaceId, parentPageId) => this.createPage(ctx, title, content, spaceId, parentPageId), - deletePage: (pageId) => this.deletePage(ctx, pageId), + deletePage: (pageId) => + this.skipIfMalformedId('delete', pageId, ctx, () => + this.deletePage(ctx, pageId), + ), movePage: (pageId, parentPageId, position) => - this.movePage(ctx, pageId, parentPageId, position), - renamePage: (pageId, title) => this.renamePage(ctx, pageId, title), + this.skipIfMalformedId('move', pageId, ctx, () => + this.movePage(ctx, pageId, parentPageId, position), + ), + renamePage: (pageId, title) => + this.skipIfMalformedId('rename', pageId, ctx, () => + 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), + restorePage: (pageId) => + this.skipIfMalformedId('restore', pageId, ctx, () => + this.restorePage(ctx, pageId), + ), }; } + /** + * Run an id-scoped write op; if `pageId` was a malformed NON-UUID token (a + * broken/hand-edited vault `gitmost_id`, e.g. `gitmost_id: [unclosed`), Postgres + * rejects it at the `uuid` predicate with error code `22P02` + * ("invalid input syntax for type uuid"). Left unhandled, the push apply records + * that throw as a per-cycle failure that NEVER clears — refs never advance, so + * the WHOLE space's sync loops on the same failure indefinitely (bug C9-D1). + * Swallow exactly that error as an inert no-op so the cycle succeeds and the rest + * of the space keeps syncing; re-throw anything else. `pageId` is the only + * user-influenced uuid in these ops, so a 22P02 here unambiguously means it. + */ + private async skipIfMalformedId( + op: string, + pageId: string, + ctx: GitSyncBindContext, + run: () => Promise, + fallback?: T, + ): Promise { + try { + return await run(); + } catch (err) { + if ((err as { code?: string })?.code === '22P02') { + this.logger.warn( + `git-sync[${ctx.spaceId ?? '-'}] skip ${op} of page '${pageId}': malformed (non-UUID) gitmost_id ignored (no wedge)`, + ); + return fallback; + } + throw err; + } + } + // --- reads (pull) --------------------------------------------------------- /**