From dde1880321418b13ec4bd3264ccf41f7cfe845cd Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 04:07:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(git-sync):=20thin-meta=20phase=201=20?= =?UTF-8?q?=E2=80=94=20the=20.gitmost/index.json=20sidecar=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure read/write/lookup for the vault sidecar index that will hold page identity (pageId) + collision token (slugId) keyed by file path, so the .md files can be clean markdown. parseVaultIndex is tolerant (missing/garbage/bad entries degrade to empty/skipped — never crashes a cycle); serializeVaultIndex is deterministic (sorted keys -> stable diffs, no churn). Lookups (pageIdAt, pathForPageId reverse, trackedPageIds) + mutations (set/remove/move). NOT wired into pull/push yet — no behavior change. 5 unit tests; engine suite green. Co-Authored-By: Claude Opus 4.8 --- packages/git-sync/src/engine/vault-index.ts | 154 ++++++++++++++++++++ packages/git-sync/src/index.ts | 15 ++ packages/git-sync/test/vault-index.test.ts | 78 ++++++++++ 3 files changed, 247 insertions(+) create mode 100644 packages/git-sync/src/engine/vault-index.ts create mode 100644 packages/git-sync/test/vault-index.test.ts diff --git a/packages/git-sync/src/engine/vault-index.ts b/packages/git-sync/src/engine/vault-index.ts new file mode 100644 index 00000000..b18f8481 --- /dev/null +++ b/packages/git-sync/src/engine/vault-index.ts @@ -0,0 +1,154 @@ +/** + * The vault SIDECAR index — `.gitmost/index.json`. It holds the ONLY service + * metadata that is not derivable from the vault itself: a page's stable identity + * (`pageId`) and its collision-disambiguation token (`slugId`), keyed by the + * file's vault-relative (forward-slash) path. Everything else is derived: + * - title -> the file/folder name (stem), + * - parentPageId-> the enclosing folder's `index.md` (path-as-truth), + * - spaceId -> the vault is the space, + * - updatedAt -> git history. + * + * Keeping identity here (not in a `docmost:meta` block inside every file) lets + * the `.md` files stay CLEAN markdown that any third-party editor (Obsidian, …) + * reads and writes directly. This module is PURE (parse/serialize/lookup); all + * file IO is the caller's (injected), matching the rest of the engine. + */ + +/** Where the sidecar lives inside a space vault (vault-relative, forward-slash). */ +export const VAULT_INDEX_PATH = ".gitmost/index.json"; + +/** Per-file identity record. `slugId` is optional (a freshly adopted file has + * none until Docmost assigns one on create). */ +export interface VaultIndexEntry { + pageId: string; + slugId?: string; +} + +export interface VaultIndex { + version: number; + /** The space this vault mirrors (one repo per space). Informational. */ + spaceId?: string; + /** file path (forward-slash, vault-relative) -> identity. */ + pages: Map; +} + +const CURRENT_VERSION = 1; + +export function emptyVaultIndex(spaceId?: string): VaultIndex { + return { version: CURRENT_VERSION, spaceId, pages: new Map() }; +} + +/** + * Parse `.gitmost/index.json`. TOLERANT by construction — a missing file + * (`null`), invalid JSON, or a malformed entry must never crash a sync cycle, so + * those degrade to an empty index / skipped entries (the engine then treats the + * affected files as un-tracked and re-derives identity, rather than losing data). + */ +export function parseVaultIndex(text: string | null | undefined): VaultIndex { + if (text == null || text.trim() === "") return emptyVaultIndex(); + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return emptyVaultIndex(); + } + if (typeof raw !== "object" || raw === null) return emptyVaultIndex(); + const obj = raw as Record; + const index = emptyVaultIndex( + typeof obj.spaceId === "string" ? obj.spaceId : undefined, + ); + if (typeof obj.version === "number") index.version = obj.version; + const pages = obj.pages; + if (typeof pages === "object" && pages !== null) { + for (const [path, value] of Object.entries(pages as Record)) { + if (typeof value !== "object" || value === null) continue; + const entry = value as Record; + if (typeof entry.pageId !== "string" || entry.pageId === "") continue; + index.pages.set(path, { + pageId: entry.pageId, + ...(typeof entry.slugId === "string" ? { slugId: entry.slugId } : {}), + }); + } + } + return index; +} + +/** + * Serialize to STABLE JSON: object keys sorted so the file produces minimal, + * deterministic git diffs (a re-sync that changes nothing yields byte-identical + * output — no churn, which the loop-guard relies on). Trailing newline. + */ +export function serializeVaultIndex(index: VaultIndex): string { + const pages: Record = {}; + for (const path of [...index.pages.keys()].sort()) { + const e = index.pages.get(path)!; + pages[path] = e.slugId + ? { pageId: e.pageId, slugId: e.slugId } + : { pageId: e.pageId }; + } + const out: Record = { version: index.version }; + if (index.spaceId) out.spaceId = index.spaceId; + out.pages = pages; + return JSON.stringify(out, null, 2) + "\n"; +} + +// --- lookups (pure) -------------------------------------------------------- + +/** The pageId tracked at `path`, or undefined. */ +export function pageIdAt(index: VaultIndex, path: string): string | undefined { + return index.pages.get(path)?.pageId; +} + +/** The slugId tracked at `path`, or undefined. */ +export function slugIdAt(index: VaultIndex, path: string): string | undefined { + return index.pages.get(path)?.slugId; +} + +/** + * Reverse lookup: the CURRENT path of a pageId, or undefined. Used by push to + * decide identity — a vanished file whose pageId still resolves to a (different) + * tracked path is a MOVE, not a delete. + */ +export function pathForPageId( + index: VaultIndex, + pageId: string, +): string | undefined { + for (const [path, entry] of index.pages) { + if (entry.pageId === pageId) return path; + } + return undefined; +} + +/** The set of all pageIds currently tracked in the index. */ +export function trackedPageIds(index: VaultIndex): Set { + const ids = new Set(); + for (const entry of index.pages.values()) ids.add(entry.pageId); + return ids; +} + +// --- mutations (in place; the index is a builder during a cycle) ----------- + +export function setEntry( + index: VaultIndex, + path: string, + entry: VaultIndexEntry, +): void { + index.pages.set(path, entry); +} + +export function removeAt(index: VaultIndex, path: string): void { + index.pages.delete(path); +} + +/** Move a tracked entry from one path to another (a rename/reparent), keeping + * its identity. No-op if `fromPath` is not tracked. */ +export function moveEntry( + index: VaultIndex, + fromPath: string, + toPath: string, +): void { + const entry = index.pages.get(fromPath); + if (!entry) return; + index.pages.delete(fromPath); + index.pages.set(toPath, entry); +} diff --git a/packages/git-sync/src/index.ts b/packages/git-sync/src/index.ts index dc87a8cb..582f132d 100644 --- a/packages/git-sync/src/index.ts +++ b/packages/git-sync/src/index.ts @@ -119,3 +119,18 @@ export type { RunCycleResult, CycleFs, } from "./engine/cycle"; + +export { + VAULT_INDEX_PATH, + emptyVaultIndex, + parseVaultIndex, + serializeVaultIndex, + pageIdAt, + slugIdAt, + pathForPageId, + trackedPageIds, + setEntry, + removeAt, + moveEntry, +} from "./engine/vault-index"; +export type { VaultIndex, VaultIndexEntry } from "./engine/vault-index"; diff --git a/packages/git-sync/test/vault-index.test.ts b/packages/git-sync/test/vault-index.test.ts new file mode 100644 index 00000000..c37800c0 --- /dev/null +++ b/packages/git-sync/test/vault-index.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { + emptyVaultIndex, + parseVaultIndex, + serializeVaultIndex, + pageIdAt, + slugIdAt, + pathForPageId, + trackedPageIds, + setEntry, + removeAt, + moveEntry, +} from "../src/engine/vault-index"; + +describe("vault-index parse/serialize", () => { + it("round-trips a populated index", () => { + const idx = emptyVaultIndex("sp1"); + setEntry(idx, "Проект/index.md", { pageId: "p1", slugId: "Ab12" }); + setEntry(idx, "Заметка.md", { pageId: "p2" }); + const text = serializeVaultIndex(idx); + const back = parseVaultIndex(text); + expect(back.spaceId).toBe("sp1"); + expect(pageIdAt(back, "Проект/index.md")).toBe("p1"); + expect(slugIdAt(back, "Проект/index.md")).toBe("Ab12"); + expect(pageIdAt(back, "Заметка.md")).toBe("p2"); + expect(slugIdAt(back, "Заметка.md")).toBeUndefined(); + }); + + it("serializes deterministically (sorted keys -> stable diffs)", () => { + const a = emptyVaultIndex("s"); + setEntry(a, "b.md", { pageId: "2" }); + setEntry(a, "a.md", { pageId: "1" }); + const b = emptyVaultIndex("s"); + setEntry(b, "a.md", { pageId: "1" }); + setEntry(b, "b.md", { pageId: "2" }); + // insertion order differs; serialized output must be identical. + expect(serializeVaultIndex(a)).toBe(serializeVaultIndex(b)); + // keys are sorted in the output + expect(serializeVaultIndex(a).indexOf('"a.md"')).toBeLessThan( + serializeVaultIndex(a).indexOf('"b.md"'), + ); + }); + + it("is tolerant: null / garbage / bad entries -> empty or skipped", () => { + expect(parseVaultIndex(null).pages.size).toBe(0); + expect(parseVaultIndex("").pages.size).toBe(0); + expect(parseVaultIndex("not json{").pages.size).toBe(0); + expect(parseVaultIndex("[1,2,3]").pages.size).toBe(0); + // a page entry missing pageId is skipped, valid ones kept + const idx = parseVaultIndex( + JSON.stringify({ version: 1, pages: { "ok.md": { pageId: "p" }, "bad.md": { slugId: "x" } } }), + ); + expect(idx.pages.size).toBe(1); + expect(pageIdAt(idx, "ok.md")).toBe("p"); + }); +}); + +describe("vault-index lookups + mutations", () => { + it("reverse lookup + tracked set", () => { + const idx = emptyVaultIndex(); + setEntry(idx, "x.md", { pageId: "px" }); + setEntry(idx, "y/index.md", { pageId: "py" }); + expect(pathForPageId(idx, "py")).toBe("y/index.md"); + expect(pathForPageId(idx, "missing")).toBeUndefined(); + expect([...trackedPageIds(idx)].sort()).toEqual(["px", "py"]); + }); + + it("moveEntry relocates identity; removeAt drops it", () => { + const idx = emptyVaultIndex(); + setEntry(idx, "Old.md", { pageId: "p", slugId: "s" }); + moveEntry(idx, "Old.md", "New/index.md"); + expect(pageIdAt(idx, "Old.md")).toBeUndefined(); + expect(pageIdAt(idx, "New/index.md")).toBe("p"); + expect(slugIdAt(idx, "New/index.md")).toBe("s"); // identity preserved + removeAt(idx, "New/index.md"); + expect(pageIdAt(idx, "New/index.md")).toBeUndefined(); + }); +});