Files
webstudio/apps/builder/app/shared/sync/builder-patch.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

153 lines
4.2 KiB
TypeScript

import type { Change } from "immerhin";
import { produceWithPatches, type Patch } from "immer";
import type {
BuilderPatch,
BuilderPatchChange,
} from "@webstudio-is/project-build/contracts";
import type { WebstudioData } from "@webstudio-is/sdk";
import type {
MarketplaceProduct,
ProjectSettings,
} from "@webstudio-is/project-build";
import * as builderStatePatch from "@webstudio-is/project-build/state";
import { serverSyncStore } from "./sync-stores";
type BuilderPatchData = WebstudioData & {
marketplaceProduct?: MarketplaceProduct;
projectSettings?: ProjectSettings;
};
const getNamespaceData = (
data: BuilderPatchData,
namespace: BuilderPatchChange["namespace"]
) => {
const namespaceData = data[namespace];
if (namespaceData !== undefined) {
return namespaceData;
}
throw Error(`Cannot apply patch for unavailable namespace "${namespace}"`);
};
const applyBuilderPatchPayloadMutable = (
data: BuilderPatchData,
payload: readonly BuilderPatchChange[]
) => {
const applicableChanges: BuilderPatchChange[] = [];
for (const change of payload) {
const [patch] = change.patches;
if (
change.namespace === "marketplaceProduct" &&
data.marketplaceProduct === undefined &&
change.patches.length === 1 &&
patch?.op === "replace" &&
patch.path.length === 0
) {
data.marketplaceProduct = structuredClone(
patch.value
) as MarketplaceProduct;
continue;
}
applicableChanges.push(change);
}
builderStatePatch.applyBuilderPatchPayloadMutable(
(namespace) => getNamespaceData(data, namespace),
applicableChanges
);
};
const clonePatch = (patch: Patch | BuilderPatch): Patch =>
patch.op === "remove"
? { op: patch.op, path: [...patch.path] }
: {
op: patch.op,
path: [...patch.path],
value: structuredClone(patch.value),
};
const cloneBuilderPatch = (patch: BuilderPatch): BuilderPatch =>
patch.op === "remove"
? { op: patch.op, path: [...patch.path] }
: {
op: patch.op,
path: [...patch.path],
value: structuredClone(patch.value),
};
const createSyncChangesFromBuilderPatches = ({
payload,
revisePatches,
}: {
payload: readonly BuilderPatchChange[];
revisePatches: readonly Patch[];
}): Change[] => {
const changes = new Map<
BuilderPatchChange["namespace"],
{ patches: Patch[]; revisePatches: Patch[] }
>();
const getChange = (namespace: BuilderPatchChange["namespace"]) => {
const change = changes.get(namespace) ?? {
patches: [],
revisePatches: [],
};
changes.set(namespace, change);
return change;
};
for (const { namespace, patches } of payload) {
if (patches.length === 0) {
continue;
}
const change = getChange(namespace);
change.patches.push(...patches.map(cloneBuilderPatch));
}
for (const patch of revisePatches) {
const [namespace, ...path] = patch.path;
if (typeof namespace !== "string") {
continue;
}
const change = getChange(namespace as BuilderPatchChange["namespace"]);
if (
namespace === "marketplaceProduct" &&
patch.op === "remove" &&
path.length === 0
) {
change.revisePatches.push({ op: "replace", path, value: undefined });
continue;
}
change.revisePatches.push(clonePatch({ ...patch, path }));
}
return Array.from(changes, ([namespace, change]) => ({
namespace,
patches: change.patches,
revisePatches: change.revisePatches,
}));
};
export const createSyncChangesFromBuilderPatchPayload = ({
data,
payload,
}: {
data: BuilderPatchData;
payload: BuilderPatchChange[];
}): Change[] => {
const payloadForReversePatches = payload.map((change) => ({
namespace: change.namespace,
patches: change.patches.map(cloneBuilderPatch),
}));
const [, , revisePatches] = produceWithPatches(data, (draft) => {
applyBuilderPatchPayloadMutable(draft, payloadForReversePatches);
});
return createSyncChangesFromBuilderPatches({ payload, revisePatches });
};
export const createTransactionFromBuilderPatchPayload = ({
data,
payload,
}: {
data: BuilderPatchData;
payload: BuilderPatchChange[];
}) => {
serverSyncStore.createTransactionFromChanges(
createSyncChangesFromBuilderPatchPayload({ data, payload })
);
};