fix(git-sync): a malformed non-UUID gitmost_id no longer wedges a space's sync (C9-D1)

A vault file with a broken/hand-edited `gitmost_id` frontmatter (e.g.
`gitmost_id: [unclosed` or a non-uuid token) fed that value into a Postgres
`uuid` predicate (page update/delete), throwing 22P02 "invalid input syntax for
type uuid". The push apply recorded it as a per-cycle failure that never cleared —
refs never advance when failures>0, so the WHOLE space's sync looped on the same
failure indefinitely and no further legitimate change synced (found via web-test).

Wrap the id-scoped write ops (import/delete/move/rename/restore) at the bind()
seam: swallow exactly the 22P02 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 there unambiguously means it.

Verified on the stand: pushing a non-UUID gitmost_id now logs a skip warn and the
space stays at 0 failures (was 1 failure/cycle forever); a concurrent legit edit
to another page still syncs. Unit tests: import/delete swallow 22P02, non-22P02
re-throws. Full server suite green (2145).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 05:33:36 +03:00
parent 67dca8c10e
commit f36a2def73
2 changed files with 95 additions and 5 deletions
@@ -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();
@@ -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<T>(
op: string,
pageId: string,
ctx: GitSyncBindContext,
run: () => Promise<T>,
fallback?: T,
): Promise<T | undefined> {
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) ---------------------------------------------------------
/**