diff --git a/.changeset/buildquery-wasm.md b/.changeset/buildquery-wasm.md new file mode 100644 index 0000000000..d23c44ddee --- /dev/null +++ b/.changeset/buildquery-wasm.md @@ -0,0 +1,5 @@ +--- +"@platforma-sdk/workflow-tengo": minor +--- + +Add `@platforma-sdk/workflow-tengo:pframes.build-query-wasm` — a wasm-backed counterpart to `:pframes.build-query` that routes through the `@milaboratories/pframes-rs-wasip2` component. Same public API (`buildQuery(input) -> map`), same `SpecQueryJoinEntry` shape; the wasm side is the rust source-of-truth (`pframes-rs/packages/spec/src/requests/build_query/{logic,request}.rs`). The pure-Tengo `:pframes.build-query` is unchanged and remains the entry point used by `:pframes.build-table`; opt-in to the wasm version by importing the new lib directly. diff --git a/lib/node/pl-middle-layer/src/mutator/template/template_cache.ts b/lib/node/pl-middle-layer/src/mutator/template/template_cache.ts index bd9de75f26..ac77e2ec2c 100644 --- a/lib/node/pl-middle-layer/src/mutator/template/template_cache.ts +++ b/lib/node/pl-middle-layer/src/mutator/template/template_cache.ts @@ -18,6 +18,7 @@ import { PlTemplateOverrideV1, PlTemplateSoftwareV1, PlTemplateV1, + PlWasmV1, } from "@milaboratories/pl-model-backend"; import type { CompiledTemplateV3, @@ -27,6 +28,7 @@ import type { TemplateLibDataV3, TemplateSoftwareData, TemplateSoftwareDataV3, + TemplateWasmDataV3, } from "@milaboratories/pl-model-backend"; import { notEmpty } from "@milaboratories/ts-helpers"; import type { BlockPackSpecPrepared } from "../../model"; @@ -150,6 +152,16 @@ function hashSoftwareV3(sw: TemplateSoftwareDataV3): string { .digest("hex"); } +function hashWasmV3(wasm: TemplateWasmDataV3): string { + return createHash("sha256") + .update(PlWasmV1.type.name) + .update(PlWasmV1.type.version) + .update(wasm.name) + .update(wasm.version) + .update(wasm.sourceHash) + .digest("hex"); +} + // ─── Tree flattening ───────────────────────────────────────────────────────── function flattenV2Tree(data: TemplateData): CacheableNode[] { @@ -316,6 +328,25 @@ function flattenV3Tree(data: CompiledTemplateV3): CacheableNode[] { return hash; } + function processWasm(wasm: TemplateWasmDataV3): string { + const hash = hashWasmV3(wasm); + if (!seen.has(hash)) { + seen.add(hash); + nodes.push({ + hash, + create: (tx) => + tx.createValue( + PlWasmV1.type, + JSON.stringify( + PlWasmV1.fromV3Data(wasm, getSourceCode(wasm.name, sources, wasm.sourceHash)).data, + ), + ), + childHashes: [], + }); + } + return hash; + } + function processTemplate(tpl: TemplateDataV3): string { // Process children first (bottom-up) const childHashes: string[] = []; @@ -341,6 +372,11 @@ function flattenV3Tree(data: CompiledTemplateV3): CacheableNode[] { childHashes.push(h); children.push({ fieldName: `${PlTemplateV1.tplPrefix}/${tplId}`, hash: h }); } + for (const [wasmId, wasm] of Object.entries(tpl.wasm ?? {})) { + const h = processWasm(wasm); + childHashes.push(h); + children.push({ fieldName: `${PlTemplateV1.wasmPrefix}/${wasmId}`, hash: h }); + } // Compose hash from own content + child hash strings (NOT child content). // Uses sourceHash directly — it already uniquely identifies the source. diff --git a/sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo b/sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo new file mode 100644 index 0000000000..8517a12067 --- /dev/null +++ b/sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo @@ -0,0 +1,92 @@ +/** + * Wasm-backed counterpart to `:pframes.build-query`. Routes through + * `assets.importWasm` so the SpecQueryJoinEntry tree is built by the + * rust source-of-truth in + * pframes-rs/packages/spec/src/requests/build_query/{logic,request}.rs. + * + * Public API mirrors `:pframes.build-query` exactly: + * `buildQuery(input) -> map` + * + * Why a separate file rather than swapping the impl behind the same path: + * importing `assets.importWasm("@milaboratories/pframes-rs-wasip2:main")` + * from a library transitively pulled in by `workflow/index.lib.tengo` would + * bundle the wasm component bytes into every block's `.plj.gz`. That + * cascades into the platform-side template-cache deduplication path + * (cached vs legacy upload produce different content hashes when wasm + * assets are present in the pack). Keep the wasm-backed entry point in + * its own library; callers opt in explicitly. + */ + +ll := import(":ll") +assets := import(":assets") +maps := import(":maps") +json := import("json") + +// Wasm-side serde requires `spec` on every linker/filter step's +// `ColumnIdAndSpec`, but `BuildQuery::execute()` reads only `columnId` — +// `spec` content is discarded (see +// pframes-rs/packages/spec/src/requests/build_query/logic.rs:39,42). The +// pure-Tengo `:pframes.build-query` impl tolerates steps with just +// `columnId`; preserve that here by stamping a synthetic placeholder on +// missing specs before serialising. +placeholderSpec := { + kind: "PColumn", + name: "_buildQuery_unused", + valueType: "Int", + axesSpec: [{ name: "_buildQuery_unused", type: "Int" }] +} + +normaliseStep := func(step) { + if step.type == "linker" && !is_undefined(step.linker) && is_undefined(step.linker.spec) { + return maps.merge(step, { + linker: maps.merge(step.linker, { spec: placeholderSpec }) + }) + } + if step.type == "filter" && !is_undefined(step.filter) && is_undefined(step.filter.spec) { + return maps.merge(step, { + filter: maps.merge(step.filter, { spec: placeholderSpec }) + }) + } + return step +} + +/** + * Assembles a SpecQueryJoinEntry from a BuildQueryInput-shaped map. + * Input schema mirrors `pframes-rs::BuildQueryInput`: + * - version: "v1" — required. + * - column: string — terminal column id. + * - path?: step entries; path[0] is outermost. Each step is + * {type: "linker", linker: {columnId, spec?, ...}} or + * {type: "filter", filter: {columnId, spec?, ...}}. + * - qualifications?: AxisQualification list attached to the outermost + * emitted entry. + * + * The `spec` field on step `linker`/`filter` is optional: if omitted, + * the SDK stamps a placeholder so the wasm-side serde accepts the + * payload. `spec` content has no effect on the result tree — only + * `columnId` is read. + * + * Returns a Tengo map matching the SpecQueryJoinEntry JSON shape. + * + * @param input: map - BuildQueryInput-shaped value. + * @return entry: map - decoded SpecQueryJoinEntry. + */ +buildQuery := func(input) { + ll.assert(ll.isMap(input), "buildQuery: input must be a map, got %v", input) + payload := input + if !is_undefined(input.path) && len(input.path) > 0 { + newPath := [] + for step in input.path { + newPath = append(newPath, normaliseStep(step)) + } + payload = maps.merge(input, { path: newPath }) + } + + wasm := assets.importWasm("@milaboratories/pframes-rs-wasip2:main")["milaboratories:pframes/spec"] + respJson := wasm.frame.buildQuery(string(json.encode(payload))) + return json.decode(respJson) +} + +export ll.toStrict({ + buildQuery: buildQuery +}) diff --git a/tests/workflow-tengo/src/pframes/build-query-wasm.tpl.tengo b/tests/workflow-tengo/src/pframes/build-query-wasm.tpl.tengo new file mode 100644 index 0000000000..8f227b4da3 --- /dev/null +++ b/tests/workflow-tengo/src/pframes/build-query-wasm.tpl.tengo @@ -0,0 +1,20 @@ +// Runs `:pframes.build-query-wasm.buildQuery` on a TS-supplied +// BuildQueryInput and emits the result as a Json resource. Used by +// build-query.test.ts (the "buildQuery (wasm lib)" suite) to validate +// that the wasm-backed lib produces the same SpecQueryJoinEntry shape +// as the pure-Tengo `:pframes.build-query` (which itself is +// cross-checked against the wasm SpecDriver byte-for-byte). + +self := import("@platforma-sdk/workflow-tengo:tpl.light") +smart := import("@platforma-sdk/workflow-tengo:smart") +bq := import("@platforma-sdk/workflow-tengo:pframes.build-query-wasm") + +self.defineOutputs("result") +self.awaitState("InputsLocked") +self.awaitState("params", "ResourceReady") + +self.body(func(inputs) { + return { + result: smart.createJsonResource(bq.buildQuery(inputs.params)) + } +}) diff --git a/tests/workflow-tengo/src/pframes/build-query.test.ts b/tests/workflow-tengo/src/pframes/build-query.test.ts index 514b33c7ac..265630b4a2 100644 --- a/tests/workflow-tengo/src/pframes/build-query.test.ts +++ b/tests/workflow-tengo/src/pframes/build-query.test.ts @@ -150,3 +150,38 @@ for (const fixture of FIXTURES) { expect(JSON.parse(JSON.stringify(tengoOutput))).toEqual(JSON.parse(JSON.stringify(wasmOutput))); }); } + +// Cross-check the wasm-backed `:pframes.build-query-wasm` lib against the +// same SpecDriver. Same fixtures, different SDK lib — proves the wasm +// wrapper produces an identical SpecQueryJoinEntry shape, including the +// synthetic placeholder spec stamped on path steps that omit it. +for (const fixture of FIXTURES) { + eTplTest.concurrent( + `buildQuery (wasm lib): ${fixture.name}`, + async ({ helper, expect, stHelper }) => { + const result = await helper.renderTemplate( + true, + "pframes.build-query-wasm", + ["result"], + (tx) => ({ + params: tx.createValue(Pl.JsonObject, JSON.stringify(fixture.input)), + }), + ); + + const r = stHelper.tree(result.resultEntry); + const finalResult = await awaitStableState(r, TIMEOUT); + assertResource(finalResult); + + const wasmLibNode = finalResult.inputs["result"]; + assertJson(wasmLibNode); + const wasmLibOutput = wasmLibNode.content; + + await using driver = new SpecDriver(); + const driverOutput = driver.buildQuery(fixture.input); + + expect(JSON.parse(JSON.stringify(wasmLibOutput))).toEqual( + JSON.parse(JSON.stringify(driverOutput)), + ); + }, + ); +}