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:
@@ -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) ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user