Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions apps/api/src/decofile/commit-coalescer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import type { GitDataClient, TreeWriteEntry } from "./github-git-data";
import { GitHubApiError } from "./github-git-data";
import {
aliasPathsForKey,
BLOB_FETCH_CONCURRENCY,
blockEntriesInTree,
blocksDirPath,
getBlockText,
mapBounded,
primeBlobCache,
primeMergedSnapshot,
resolveOrCreateHead,
} from "./read-decofile";

Expand Down Expand Up @@ -171,14 +175,19 @@ async function commitBatch(batch: Batch): Promise<string> {
const genPath = packagePath
? `${packagePath}/.deco/blocks.gen.json`
: ".deco/blocks.gen.json";
// Non-null after an in-commit regen, so it can prime the read cache below.
let genContent: string | null = null;
if (tree.some((e) => e.type === "blob" && e.path === genPath)) {
const files = await Promise.all(
[...nextBlocks.values()].map(async (b) => ({
// Disk-cache-first + bounded (see getBlockText), mirroring the read path.
const files = await mapBounded(
[...nextBlocks.values()],
BLOB_FETCH_CONCURRENCY,
async (b) => ({
stem: b.stem,
content: b.content ?? (await client.getBlobText(b.sha as string)),
})),
content: b.content ?? (await getBlockText(client, b.sha as string)),
}),
);
const { decofile: genContent, skipped } = mergeBlocks(files);
const { decofile, skipped } = mergeBlocks(files);
if (skipped.length > 0) {
console.warn("decofile gen: dropped blocks that were not valid JSON", {
repo: `${client.owner}/${client.repo}`,
Expand All @@ -187,13 +196,14 @@ async function commitBatch(batch: Batch): Promise<string> {
blocks: skipped.map((s) => s.key),
});
}
const genBlobSha = await client.createBlob(genContent);
const genBlobSha = await client.createBlob(decofile);
writes.push({
path: genPath,
mode: "100644",
type: "blob",
sha: genBlobSha,
});
genContent = decofile;
}

const newTreeSha = await client.createTree(baseTreeSha, writes);
Expand All @@ -204,6 +214,16 @@ async function commitBatch(batch: Batch): Promise<string> {
});
try {
await client.updateRef(branch, commitSha);
// The read after this save then hits the merged doc on disk (fail-open).
if (genContent !== null) {
await primeMergedSnapshot(
client.owner,
client.repo,
commitSha,
packagePath,
genContent,
);
}
return commitSha;
} catch (err) {
// Non-fast-forward: someone else advanced the branch between our head
Expand Down
38 changes: 37 additions & 1 deletion apps/api/src/decofile/read-decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function resolveOrCreateHead(
* stampedes the connection pool, flakes into timeouts, and can trip GitHub's
* secondary (abuse) rate limit. Cold reads are rare — the blob cache makes
* every subsequent read fetch only what changed. */
const BLOB_FETCH_CONCURRENCY = 12;
export const BLOB_FETCH_CONCURRENCY = 12;

/** Above this many cache-missing blobs, a single tarball download beats
* per-blob API calls (rate-limit- and latency-wise). */
Expand Down Expand Up @@ -188,6 +188,23 @@ export function primeBlobCache(
return putBlob(owner, repo, blobSha, content);
}

/**
* A block blob's text, disk-cache-first (mirrors `resolveSnapshot`'s tail
* fetch). Writers share this so the in-commit `blocks.gen.json` regeneration
* reads the warm blobs the editor's read just primed, instead of re-fetching
* every block from GitHub uncached. A miss fetches once and writes through.
*/
export async function getBlockText(
client: GitDataClient,
sha: string,
): Promise<string> {
const hit = await getBlob(client.owner, client.repo, sha);
if (hit !== null) return hit;
const content = await client.getBlobText(sha);
await putBlob(client.owner, client.repo, sha, content);
return content;
}

export interface DecofileSnapshot {
/** Branch head commit sha — the version everywhere (ETag, __draft, response). */
sha: string;
Expand All @@ -211,6 +228,25 @@ function mergedDocSha(sha: string, packagePath: string | null): string {
.digest("hex");
}

/**
* Prime the merged-doc disk cache for `(sha, packagePath)` with a document the
* writer already computed in-commit, so the preview read right after a save is
* a disk hit instead of a tree read + full re-merge. Keyed identically to
* `resolveSnapshot`'s own writes (same `mergedDocSha`/format), and the writer's
* `mergeBlocks` output is byte-for-byte what `resolveSnapshot` would produce at
* that sha, so a later read is served correct. Fail-open (`putMerged` never
* throws): a cache miss just recomputes.
*/
export function primeMergedSnapshot(
owner: string,
repo: string,
sha: string,
packagePath: string | null,
decofile: string,
): Promise<void> {
return putMerged(owner, repo, mergedDocSha(sha, packagePath), decofile);
}

/** Concurrent snapshot reads of the same content share ONE resolution — this
* exists for the thundering herd on cold reads (spec §Cold read) but wraps
* warm reads too (cheap). Keyed by content, not branch: the branch → sha
Expand Down
52 changes: 52 additions & 0 deletions packages/e2e/tests/decofile-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,58 @@ test.describe("decofile API", () => {
}
});

test("PATCH regenerates a committed blocks.gen.json from the full block set", async ({
playwright,
}) => {
const ctx = await newApiContext(playwright);
try {
const user = await signUpViaApi(ctx);
const org = user.orgSlug;
const owner = uniqueOwner();
const repo = "site";

const oldHero = { __resolveType: "site/sections/Hero.tsx", title: "old" };
const footer = { __resolveType: "site/sections/Footer.tsx", year: 2024 };
await seedStubRepo(ctx, {
owner,
repo,
defaultBranch: "main",
branches: {
main: {
files: {
".deco/blocks/Hero.json": blockFileContent(oldHero),
".deco/blocks/Footer.json": blockFileContent(footer),
// Deliberately stale (no Footer, pre-edit Hero): a pass proves a full re-merge.
".deco/blocks.gen.json": `${JSON.stringify({ Hero: oldHero })}\n`,
},
},
draft: null,
},
});
const project = await createFastPreviewProject(ctx, org, { owner, repo });
const url = decofileUrl(project, "draft");

const newHero = { __resolveType: "site/sections/Hero.tsx", title: "new" };
const patchRes = await ctx.patch(url, {
data: { set: { Hero: newHero } },
});
expect(patchRes.status()).toBe(200);

const inspected = await inspectStubRepo(ctx, owner, repo);
const genRaw =
inspected.branches["draft"]?.files[".deco/blocks.gen.json"];
expect(genRaw).toBeDefined();
const gen = JSON.parse(genRaw as string) as Record<string, unknown>;
// Edited Hero + untouched Footer: the stale seed was replaced, not key-patched.
expect(gen["Hero"]).toEqual(newHero);
expect(gen["Footer"]).toEqual(footer);
// Keys sorted by filename (Footer.json < Hero.json), matching the daemon.
expect(Object.keys(gen)).toEqual(["Footer", "Hero"]);
} finally {
await ctx.dispose();
}
});

test("large repo whose recursive tree read truncates still reads and writes blocks", async ({
playwright,
}) => {
Expand Down
Loading