feat(spa-types): add SnapshotEntry + EmbedBundle + embedBundle/hashes client
This commit is contained in:
@@ -30,6 +30,7 @@ function makeWildcardRow(id: string, name: string, opts: {
|
||||
} = {}): ModuleRow {
|
||||
return {
|
||||
id,
|
||||
uuid: "aabbccdd",
|
||||
name,
|
||||
type: "wildcard",
|
||||
description: "",
|
||||
@@ -40,6 +41,7 @@ function makeWildcardRow(id: string, name: string, opts: {
|
||||
options: (opts.values ?? []).map((v, i) => ({ id: `o${i}`, value: v, weight: 1 })),
|
||||
sub_categories: opts.subs ?? [],
|
||||
},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
|
||||
@@ -29,6 +29,7 @@ const apiAny = api as unknown as {
|
||||
function mkModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
return {
|
||||
id: "wc_aaaa1111",
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "Sample",
|
||||
description: "",
|
||||
@@ -36,6 +37,7 @@ function mkModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
payload: { options: [] },
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ModuleRow, ModuleType, PipelineStep } from "../api/types";
|
||||
function makeModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
return {
|
||||
id: "m_a",
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "alpha",
|
||||
description: "",
|
||||
@@ -13,6 +14,7 @@ function makeModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
payload: {},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ModuleRow } from "../api/types";
|
||||
vi.mock("../api/client", () => {
|
||||
const MOCK_MODULES: ModuleRow[] = [
|
||||
{
|
||||
id: "wc_a", type: "wildcard", name: "Hair Color",
|
||||
id: "wc_a", uuid: "aabbccdd", type: "wildcard", name: "Hair Color",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: {
|
||||
options: [
|
||||
@@ -16,10 +16,11 @@ vi.mock("../api/client", () => {
|
||||
sub_categories: ["warm", "cool"],
|
||||
var_binding: "hair_color",
|
||||
},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "wc_o", type: "wildcard", name: "Outfit",
|
||||
id: "wc_o", uuid: "bbbbbbbb", type: "wildcard", name: "Outfit",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: {
|
||||
options: [
|
||||
@@ -27,26 +28,29 @@ vi.mock("../api/client", () => {
|
||||
],
|
||||
sub_categories: [],
|
||||
},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "fv_a", type: "fixed_values", name: "Subject Profile",
|
||||
id: "fv_a", uuid: "cccccccc", type: "fixed_values", name: "Subject Profile",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { values: [{ var: "name", value: "Mira" }, { var: "age", value: "29" }] },
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "cb_a", type: "combine", name: "Subject Phrase",
|
||||
id: "cb_a", uuid: "dddddddd", type: "combine", name: "Subject Phrase",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: {
|
||||
template: "$name with $hair_color hair",
|
||||
output_var: "subject_phrase",
|
||||
input_vars: ["name", "hair_color"],
|
||||
},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "dv_a", type: "derivation", name: "Always Append",
|
||||
id: "dv_a", uuid: "eeeeeeee", type: "derivation", name: "Always Append",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: {
|
||||
rules: [{
|
||||
@@ -57,10 +61,11 @@ vi.mock("../api/client", () => {
|
||||
}],
|
||||
}],
|
||||
},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "cn_a", type: "constraint", name: "Hair × Outfit",
|
||||
id: "cn_a", uuid: "ffffffff", type: "constraint", name: "Hair × Outfit",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: {
|
||||
source_wildcard_id: "wc_a",
|
||||
@@ -68,15 +73,17 @@ vi.mock("../api/client", () => {
|
||||
matrix: { warm: {} },
|
||||
exceptions: [],
|
||||
},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "pl_a", type: "pipeline", name: "Quick Portrait",
|
||||
id: "pl_a", uuid: "11111111", type: "pipeline", name: "Quick Portrait",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { steps: [
|
||||
{ id: "s1", module_id: "fv_a", enabled: true },
|
||||
{ id: "s2", module_id: "wc_a", enabled: true },
|
||||
] },
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Spec §4.3 — TS twin of canonical Python SnapshotEntry shape. Pinned
|
||||
* here so a future refactor on either side surfaces a type error before
|
||||
* runtime drift can happen.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ModuleRow,
|
||||
SnapshotEntry,
|
||||
WalkOverflow,
|
||||
EmbedBundle,
|
||||
} from "../api/types";
|
||||
import { api } from "../api/client";
|
||||
|
||||
describe("API types", () => {
|
||||
it("SnapshotEntry has the seven canonical fields", () => {
|
||||
// Compile-time check: this object literal must satisfy the type.
|
||||
const entry: SnapshotEntry = {
|
||||
snapshot_version: 1,
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "color",
|
||||
payload: { options: [] },
|
||||
payload_hash: "0".repeat(64),
|
||||
source: { kind: "user" },
|
||||
};
|
||||
expect(entry.snapshot_version).toBe(1);
|
||||
});
|
||||
|
||||
it("SnapshotEntry source supports kind=dep with parent_uuids", () => {
|
||||
const entry: SnapshotEntry = {
|
||||
snapshot_version: 1, uuid: "a1b2c3d4", type: "wildcard", name: "x",
|
||||
payload: {}, payload_hash: "h".repeat(64),
|
||||
source: { kind: "dep", parent_uuids: ["aabbccdd"] },
|
||||
};
|
||||
expect((entry.source as { kind: "dep"; parent_uuids: string[] }).parent_uuids)
|
||||
.toEqual(["aabbccdd"]);
|
||||
});
|
||||
|
||||
it("WalkOverflow shape", () => {
|
||||
const overflows: WalkOverflow[] = [
|
||||
{ uuid: "x", reason: "max_depth" },
|
||||
{ uuid: "y", reason: "cycle_detected" },
|
||||
{ uuid: "z", reason: "missing_target" },
|
||||
];
|
||||
expect(overflows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("EmbedBundle has flat envelope shape", () => {
|
||||
const bundle: EmbedBundle = {
|
||||
modules: [{ type: "wildcard" }],
|
||||
snapshots: {},
|
||||
pickOrder: ["aabbccdd"],
|
||||
walkOverflow: [],
|
||||
};
|
||||
expect(bundle.pickOrder).toEqual(["aabbccdd"]);
|
||||
});
|
||||
|
||||
it("ModuleRow includes uuid and payload_hash", () => {
|
||||
const row: ModuleRow = {
|
||||
id: "wc_x_aabbccdd",
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "x",
|
||||
description: "",
|
||||
category_id: null,
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
payload: {},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1,
|
||||
created_at: "2026-04-28T00:00:00.000000Z",
|
||||
updated_at: "2026-04-28T00:00:00.000000Z",
|
||||
};
|
||||
expect(row.uuid).toBe("aabbccdd");
|
||||
expect(row.payload_hash).toBeDefined();
|
||||
});
|
||||
|
||||
it("client exposes embedBundle and hashes methods", () => {
|
||||
expect(typeof api.modules.embedBundle).toBe("function");
|
||||
expect(typeof api.modules.hashes).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import type { CategoryRow, ModuleRow } from "../api/types";
|
||||
function mkModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
return {
|
||||
id: "wc_aaaa1111",
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "Sample",
|
||||
description: "",
|
||||
@@ -26,6 +27,7 @@ function mkModule(over: Partial<ModuleRow>): ModuleRow {
|
||||
tags: [],
|
||||
is_favorite: false,
|
||||
payload: {},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeWildcard(
|
||||
): ModuleRow {
|
||||
return {
|
||||
id: extras.id ?? `wc_${name.toLowerCase()}`,
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name,
|
||||
description: "",
|
||||
@@ -40,6 +41,7 @@ function makeWildcard(
|
||||
sub_categories: extras.sub_categories ?? [],
|
||||
var_binding: extras.var_binding,
|
||||
},
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
@@ -255,9 +257,10 @@ describe("runStep", () => {
|
||||
|
||||
it("runs a fixed_values step writing every binding into ctx", () => {
|
||||
const f: ModuleRow = {
|
||||
id: "fv1", type: "fixed_values", name: "Pack",
|
||||
id: "fv1", uuid: "aabbccdd", type: "fixed_values", name: "Pack",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { values: [{ var: "city", value: "Tokyo" }, { name: "$ward", value: "Shibuya" }] },
|
||||
payload_hash: "0".repeat(64),
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
};
|
||||
const step: PipelineStep = { id: "s1", module_id: f.id, enabled: true };
|
||||
|
||||
@@ -23,6 +23,7 @@ function wc(
|
||||
}
|
||||
return {
|
||||
id: partial.id ?? `wc_${partial.name}`,
|
||||
uuid: partial.uuid ?? "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: partial.name,
|
||||
description: partial.description ?? "",
|
||||
@@ -30,6 +31,7 @@ function wc(
|
||||
tags: partial.tags ?? [],
|
||||
is_favorite: partial.is_favorite ?? false,
|
||||
payload,
|
||||
payload_hash: partial.payload_hash ?? "0".repeat(64),
|
||||
version: partial.version ?? 1,
|
||||
created_at: partial.created_at ?? "",
|
||||
updated_at: partial.updated_at ?? "",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CategoryCreateInput, CategoryRow,
|
||||
EmbedBundle,
|
||||
ImportBundle, ImportResult,
|
||||
MatchRequest, MatchResponse,
|
||||
ModuleCreateInput, ModuleListResponse, ModuleRow, ModuleUpdateInput,
|
||||
@@ -91,6 +92,16 @@ export const api = {
|
||||
method: "POST", body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
embedBundle(uuids: string[]) {
|
||||
return request<EmbedBundle>("/wp/api/modules/embed-bundle", {
|
||||
method: "POST", body: JSON.stringify({ uuids }),
|
||||
});
|
||||
},
|
||||
hashes() {
|
||||
return request<{ hashes: Record<string, string> }>(
|
||||
"/wp/api/modules/hashes", { method: "GET" },
|
||||
);
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
list() {
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface PipelinePayload {
|
||||
|
||||
export interface ModuleRow {
|
||||
id: string;
|
||||
uuid: string; // spec §4.2
|
||||
type: ModuleType;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -114,6 +115,7 @@ export interface ModuleRow {
|
||||
tags: string[];
|
||||
is_favorite: boolean;
|
||||
payload: Record<string, unknown>;
|
||||
payload_hash: string; // spec §4.2
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -224,3 +226,53 @@ export interface ImportResult {
|
||||
categories_imported: number;
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec §2.4 — canonical snapshot entry. Mirrors the Python
|
||||
* `engine.modules.snapshot.SnapshotEntry` TypedDict. Stored:
|
||||
* - in `__wp_catalog__` at runtime (memory only)
|
||||
* - in WP_Context workflow JSON (persisted, decoupled from DB)
|
||||
*
|
||||
* `payload_hash` covers `payload` only (not `name` / `tags` / etc.) so a
|
||||
* rename does not flip drift state.
|
||||
*/
|
||||
export interface SnapshotEntry {
|
||||
snapshot_version: 1;
|
||||
uuid: string; // 8 hex chars
|
||||
type: ModuleType; // wildcards-only in catalog (spec §2.7)
|
||||
name: string;
|
||||
payload: Record<string, unknown>;
|
||||
payload_hash: string; // SHA-256 hex, 64 chars
|
||||
source:
|
||||
| { kind: "user" }
|
||||
| { kind: "dep"; parent_uuids: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walker-recorded anomaly. Spec §2.8 — extensible discriminator. New
|
||||
* reasons can be added without breaking existing consumers; they
|
||||
* default-handle unknown variants.
|
||||
*/
|
||||
export interface WalkOverflow {
|
||||
uuid: string;
|
||||
reason: "max_depth" | "cycle_detected" | "missing_target";
|
||||
}
|
||||
|
||||
/**
|
||||
* Server response shape from `POST /wp/api/modules/embed-bundle`.
|
||||
* Pre-split so the SPA picker doesn't filter wildcards out of a mixed
|
||||
* bundle. Spec §4.2.
|
||||
*
|
||||
* - `modules`: full payloads of explicit picks, in input order. The
|
||||
* pipeline executes these.
|
||||
* - `snapshots`: wildcard catalog (picks of type wildcard + transitive
|
||||
* wildcards). Becomes `ctx['__wp_catalog__']` at run time.
|
||||
* - `pickOrder`: uuid order for UI display.
|
||||
* - `walkOverflow`: cycles / depth caps / missing targets the walker hit.
|
||||
*/
|
||||
export interface EmbedBundle {
|
||||
modules: Record<string, unknown>[];
|
||||
snapshots: Record<string, SnapshotEntry>;
|
||||
pickOrder: string[];
|
||||
walkOverflow: WalkOverflow[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user