Files
webstudio/apps/builder/app/shared/sync/builder-patch.test.ts
Oleg Isonen 3ccb46cec7 fix: restore Marketplace settings and optimize Assets publishing (#5964)
## Outcome

Restore the Marketplace section for projects without existing
marketplace metadata, make the complete review-submission workflow
available over MCP, clarify shared Assets database diagnostics, and
reduce published database size by materializing fully static Assets
queries.

## Implementation

### Marketplace and MCP

- Initialize the Marketplace form from a safe partial default instead of
rendering nothing when metadata is unconfigured.
- Keep review submission disabled until the complete metadata schema is
valid.
- Allow `get-marketplace-product` to return `null` and
`update-marketplace-product` to create the first marketplace product
through ProjectSession/MCP.
- Add a confirmed `submit-marketplace-product` server operation that
validates configured metadata before setting review status to `PENDING`.
- Document the MCP workflow: upload the thumbnail, update metadata,
publish production, then submit.
- Correct the Marketplace thumbnail upload example to use the current
MCP input field.

### Assets database

- Show query size and merged published-database size separately in
resource diagnostics.
- Clarify that only the merged database counts toward the publish limit.
- Allow the Assets query editor to scroll long query source.
- Materialize fully static metadata-only Assets queries at publish time.
- Store materialized results as compact field columns and rows keyed by
a deterministic normalized-query hash.
- Serve materialized rows through the existing Assets response contract
without candidate documents or a runtime field catalog.
- Keep route- or variable-dependent queries on the existing
document-backed runtime path.
- Exclude overview-only fields from dynamic detail documents so the
merged database does not duplicate them.
- Preserve byte-limit selection, truncation diagnostics, integrity
verification, caching, and backward compatibility with existing
artifacts.

### Release

- Keep the release lockfile stable when preparing the CLI package.
- Regenerate runtime contracts, server-only API metadata, and CLI
documentation where required.

## Todo

- [x] Restore the empty Marketplace settings state
- [x] Prevent empty review submissions
- [x] Support first-time Marketplace metadata creation over MCP
- [x] Add MCP review submission
- [x] Document the end-to-end MCP workflow
- [x] Correct query and merged-database diagnostics
- [x] Make long Assets query source scrollable
- [x] Materialize static overview queries
- [x] Preserve dynamic detail-query behavior
- [x] Add regression coverage with fail/pass proof
- [x] Run relevant package and integration checks

## Verification

- [x] Content engine tests: 158 passed
- [x] Asset uploader tests: 179 passed
- [x] SDK tests: 505 passed
- [x] CLI tests: 921 passed
- [x] CLI prebuild integration tests: 46 passed
- [x] Builder Assets resource and diagnostics tests: 14 passed
- [x] Project runtime and ProjectSession tests: 133 passed
- [x] Builder API router tests: 30 passed
- [x] Protocol operation tests: 18 passed
- [x] Content engine build and declaration generation
- [x] Content engine, asset uploader, SDK, Builder, and CLI typechecks
- [x] Repository lint and formatting checks
- [x] Generated API and documentation checks
- [x] New materialization tests demonstrated failing with the
implementation disabled and passing after restoration

## Notes

- The representative five-post overview fixture shrinks from 2,122 bytes
to 1,197 bytes while returning the same sorted and paginated result.
- Materialization is intentionally limited to static, metadata-only
field queries without runtime asset fields. Unsupported or
data-incompatible queries safely use the existing runtime database.
- The complete `pnpm evaluations` agent suite was not run because
repository policy requires separate explicit approval.
- No database schema or migration changes are required.
2026-07-31 13:16:21 +02:00

372 lines
9.5 KiB
TypeScript

import { describe, expect, test } from "vitest";
import type { Instance, WebstudioData } from "@webstudio-is/sdk";
import { createDefaultPages } from "@webstudio-is/project-build";
import type {
MarketplaceProduct,
ProjectSettings,
} from "@webstudio-is/project-build";
import type { BuilderPatchChange } from "@webstudio-is/project-build/contracts";
import {
createSyncChangesFromBuilderPatchPayload,
createTransactionFromBuilderPatchPayload,
} from "./builder-patch";
import { registerContainers, serverSyncStore } from "./sync-stores";
import {
$assets,
$breakpoints,
$dataSources,
$instances,
$marketplaceProduct,
$pages,
$projectSettings,
$props,
$resources,
$styleSourceSelections,
$styleSources,
$styles,
} from "./data-stores";
registerContainers();
const createInstance = (
id: Instance["id"],
children: Instance["children"] = []
): Instance => ({
type: "instance",
id,
component: "ws:element",
tag: "div",
children,
});
type TestData = WebstudioData & {
marketplaceProduct?: MarketplaceProduct;
projectSettings: ProjectSettings;
};
const createData = (): TestData => ({
pages: createDefaultPages({ rootInstanceId: "body" }),
instances: new Map([
["body", createInstance("body")],
["existing", createInstance("existing", [{ type: "id", value: "old" }])],
["removed", createInstance("removed")],
]),
props: new Map(),
dataSources: new Map(),
resources: new Map(),
breakpoints: new Map(),
styleSourceSelections: new Map(),
styleSources: new Map(),
styles: new Map(),
assets: new Map(),
projectSettings: { meta: {}, compiler: {} },
});
const setupStores = (data: TestData) => {
serverSyncStore.transactionManager.currentStack = [];
serverSyncStore.transactionManager.undoneStack = [];
serverSyncStore.popAll();
$pages.set(data.pages);
$instances.set(data.instances);
$props.set(data.props);
$dataSources.set(data.dataSources);
$resources.set(data.resources);
$breakpoints.set(data.breakpoints);
$styleSourceSelections.set(data.styleSourceSelections);
$styleSources.set(data.styleSources);
$styles.set(data.styles);
$assets.set(data.assets);
$marketplaceProduct.set(data.marketplaceProduct);
$projectSettings.set(data.projectSettings);
};
describe("builder patch sync adapter", () => {
test("converts builder patches into sync changes with reverse patches", () => {
const data = createData();
const added = createInstance("added");
const payload: BuilderPatchChange[] = [
{ namespace: "props", patches: [] },
{
namespace: "instances",
patches: [
{ op: "add", path: ["added"], value: added },
{
op: "add",
path: ["added", "children", 0],
value: { type: "id", value: "child" },
},
{
op: "replace",
path: ["existing", "children", 0, "value"],
value: "new",
},
{ op: "remove", path: ["removed"] },
],
},
];
const changes = createSyncChangesFromBuilderPatchPayload({
data,
payload,
});
expect(changes).toHaveLength(1);
const [change] = changes;
expect(change.namespace).toEqual("instances");
expect(change.patches).toEqual([
{
op: "add",
path: ["added"],
value: createInstance("added"),
},
{
op: "add",
path: ["added", "children", 0],
value: { type: "id", value: "child" },
},
{
op: "replace",
path: ["existing", "children", 0, "value"],
value: "new",
},
{ op: "remove", path: ["removed"] },
]);
expect(change.revisePatches).toEqual(
expect.arrayContaining([
{ op: "remove", path: ["added"] },
{
op: "replace",
path: ["existing", "children", 0, "value"],
value: "old",
},
{ op: "add", path: ["removed"], value: createInstance("removed") },
])
);
added.children.push({ type: "id", value: "mutated-after-conversion" });
expect(change.patches[0]).toEqual({
op: "add",
path: ["added"],
value: createInstance("added"),
});
});
test("compacts reverse patches for undo", () => {
const data = createData();
const added = createInstance("added");
const payload: BuilderPatchChange[] = [
{
namespace: "instances",
patches: [
{ op: "add", path: ["added"], value: added },
{
op: "add",
path: ["added", "children", 0],
value: { type: "id", value: "child" },
},
],
},
];
const changes = createSyncChangesFromBuilderPatchPayload({
data,
payload,
});
expect(changes).toHaveLength(1);
const [change] = changes;
expect(change.revisePatches).toEqual([
{
op: "remove",
path: ["added"],
},
]);
expect(change.patches).toEqual([
{
op: "add",
path: ["added"],
value: createInstance("added"),
},
{
op: "add",
path: ["added", "children", 0],
value: { type: "id", value: "child" },
},
]);
});
test("preserves runtime add patches for undefined object fields", () => {
const data = createData();
const page = data.pages.pages.get(data.pages.homePageId);
if (page === undefined) {
throw Error("Expected fixture page");
}
page.marketplace = {
include: true,
category: undefined,
thumbnailAssetId: undefined,
};
const changes = createSyncChangesFromBuilderPatchPayload({
data,
payload: [
{
namespace: "pages",
patches: [
{
op: "add",
path: ["pages", data.pages.homePageId, "marketplace", "category"],
value: "Runtime Examples",
},
],
},
],
});
expect(changes).toEqual([
{
namespace: "pages",
patches: [
{
op: "add",
path: ["pages", data.pages.homePageId, "marketplace", "category"],
value: "Runtime Examples",
},
],
revisePatches: [
{
op: "replace",
path: ["pages", data.pages.homePageId, "marketplace", "category"],
value: undefined,
},
],
},
]);
});
test("commits builder patch payloads through sync undo and redo", () => {
const data = createData();
setupStores(data);
createTransactionFromBuilderPatchPayload({
data,
payload: [
{
namespace: "instances",
patches: [
{
op: "replace",
path: ["existing", "children", 0, "value"],
value: "new",
},
],
},
],
});
expect($instances.get().get("existing")?.children).toEqual([
{ type: "id", value: "new" },
]);
serverSyncStore.undo();
expect($instances.get().get("existing")?.children).toEqual([
{ type: "id", value: "old" },
]);
serverSyncStore.redo();
expect($instances.get().get("existing")?.children).toEqual([
{ type: "id", value: "new" },
]);
});
test("commits project settings through sync undo and redo", () => {
const data = createData();
data.projectSettings.meta.siteName = "Before";
setupStores(data);
createTransactionFromBuilderPatchPayload({
data,
payload: [
{
namespace: "projectSettings",
patches: [
{
op: "replace",
path: ["meta", "siteName"],
value: "After",
},
],
},
],
});
expect($projectSettings.get()?.meta.siteName).toBe("After");
serverSyncStore.undo();
expect($projectSettings.get()?.meta.siteName).toBe("Before");
serverSyncStore.redo();
expect($projectSettings.get()?.meta.siteName).toBe("After");
});
test("creates marketplace metadata through sync undo and redo", () => {
const data = createData();
const marketplaceProduct = {
category: "pageTemplates" as const,
name: "Acme Template",
thumbnailAssetId: "asset-id",
author: "Acme Studio",
email: "hello@example.com",
website: "https://example.com",
issues: "",
description: "Reusable template project for Acme landing pages.",
};
setupStores(data);
createTransactionFromBuilderPatchPayload({
data,
payload: [
{
namespace: "marketplaceProduct",
patches: [{ op: "replace", path: [], value: marketplaceProduct }],
},
],
});
expect($marketplaceProduct.get()).toEqual(marketplaceProduct);
serverSyncStore.undo();
expect($marketplaceProduct.get()).toBeUndefined();
serverSyncStore.redo();
expect($marketplaceProduct.get()).toEqual(marketplaceProduct);
});
test("commits style source selection removals through sync undo and redo", () => {
const data = createData();
data.styleSourceSelections.set("card", {
instanceId: "card",
values: ["local"],
});
setupStores(data);
createTransactionFromBuilderPatchPayload({
data,
payload: [
{
namespace: "styleSourceSelections",
patches: [{ op: "remove", path: ["card"] }],
},
],
});
expect($styleSourceSelections.get()).toEqual(new Map());
serverSyncStore.undo();
expect($styleSourceSelections.get()).toEqual(
new Map([["card", { instanceId: "card", values: ["local"] }]])
);
serverSyncStore.redo();
expect($styleSourceSelections.get()).toEqual(new Map());
});
});