fix(git-sync): close #119 blockers — dead edit-revert guard, cross-space guard, red suite (F5/S2/G1/A1/F7)

F5 (HIGH data-loss): guard #2 (GS-EDIT-REVERT) called a local key-sorting equality that
never matched a real page (block ids + materialized defaults differ), so the guard was
dead and a web edit on a git-sync space was silently reverted within one poll cycle. Use
the package's authoritative docsCanonicallyEqual (strips block id + normalizes
KNOWN_DEFAULTS), wired through the git-sync loader like sanitizeTitle; delete the dead
local canonicalize/canonicalJsonEqual.
S2 (security): importPageMarkdown targeted a page by the vault-file id without a spaceId
check (deletePage had one) — a space-A vault file carrying space-B's page id could
resurrect/overwrite/clear B's page. Mirror deletePage's guard: skip when the loaded page
lives in a different space than ctx.spaceId.
G1 (jest green): add sanitizeTitle + docsCanonicallyEqual to the loadGitSync mock; update
the converter-gate + package golden expectations to the genuinely-fixed output (paragraph
textAlign now round-trips, multi-block table cells emit HTML tables); fix the orchestrator
spec's stale mock so the per-space enabled gate (added later) is satisfied.
A1: the converter dropped heading textAlign on export (bare '## text'); emit a styled
<hN> when aligned, symmetric to paragraphs — round-trips losslessly (level + align), no
churn for unaligned headings.
F7 (docs): reword the false 'single choke point' title-strip comment; correct push.ts
docstrings that still described the removed standalone-CLI/daemon model.

Adds regression tests: the F5 acceptance test (canonically-equal content with real uuids
=> writePageBody NOT called), the S2 cross-space import guard, and the A1 heading
round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-03 00:13:08 +03:00
parent 320b200ac8
commit 5d45f5a85e
9 changed files with 362 additions and 111 deletions
@@ -56,10 +56,19 @@ interface BuildOptions {
vaultOverrides?: Record<string, unknown>;
/**
* The row `buildSettings` reads for the per-space `autoMergeConflicts` flag
* (`executeTakeFirst`). Default: the SAFE off value. Pass `undefined` to model
* a missing row (no space / no settings).
* (`executeTakeFirst` on the `autoMergeConflicts`-aliased query). Default: the
* SAFE off value. Pass `undefined` to model a missing row (no space / no
* settings).
*/
settingsRow?: { autoMergeConflicts: boolean } | undefined;
/**
* The per-space opt-in flag `runOnce` reads via `isSpaceGitSyncEnabled`
* (`executeTakeFirst` on the `enabled`-aliased query — a DIFFERENT query from
* the `autoMergeConflicts` read above; commit c838fdee added it). Default
* `true` so a build() space passes the per-space gate and the cycle proceeds.
* Set `false` to model a space that did NOT opt in (skipped:'space-not-enabled').
*/
spaceEnabled?: boolean;
}
interface Built {
@@ -83,6 +92,7 @@ function build(opts: BuildOptions = {}): Built {
pollIntervalMs = 15000,
debounceMs = 2000,
vaultOverrides = {},
spaceEnabled = true,
} = opts;
// Distinguish "key omitted" (default off row) from "key present but undefined"
// (a deliberately MISSING settings row).
@@ -138,15 +148,36 @@ function build(opts: BuildOptions = {}): Built {
};
const redisService = { getOrThrow: jest.fn(() => redis) };
// Chainable Kysely stub. `buildSettings` reads the space's
// `gitSync.autoMergeConflicts` flag via
// `selectFrom('spaces').select(...).where('id','=',id).executeTakeFirst()`;
// default it to the SAFE off value. `enabledSpaces` uses `.execute()`.
// Chainable Kysely stub. TWO distinct single-row queries run through the same
// builder and both end in `executeTakeFirst`, so the stub must tell them apart
// by the column ALIAS the real code selects (they are otherwise identical):
// - `isSpaceGitSyncEnabled` (per-space opt-in gate in `runOnce`, added by
// c838fdee): `.select(sql\`...->>'enabled' = 'true'\`.as('enabled'))` ->
// the code reads `row?.enabled`. Return `{ enabled: spaceEnabled }`.
// - `buildSettings`: `.select(sql\`...->>'autoMergeConflicts' = 'true'\`
// .as('autoMergeConflicts'))` -> the code reads `row?.autoMergeConflicts`.
// Return `settingsRow` (default SAFE off; `undefined` models a missing row).
// `enabledSpaces` uses `.select([...strings]).execute()` (no alias, no
// executeTakeFirst) and is stubbed/replaced in the tests that exercise it.
const db = (() => {
let lastAlias: string | undefined;
const aliasOf = (sel: unknown): string | undefined => {
try {
const node = (sel as { toOperationNode?: () => any })?.toOperationNode?.();
return node?.kind === 'AliasNode' ? node.alias?.name : undefined;
} catch {
return undefined;
}
};
const builder: any = {
select: () => builder,
select: (sel: unknown) => {
const alias = aliasOf(sel);
if (alias) lastAlias = alias;
return builder;
},
where: () => builder,
executeTakeFirst: async () => settingsRow,
executeTakeFirst: async () =>
lastAlias === 'enabled' ? { enabled: spaceEnabled } : settingsRow,
execute: async () => [],
};
return { selectFrom: () => builder };