From 4cb4fc06405da3d4f4ddc6c65f33e45b9393996c Mon Sep 17 00:00:00 2001 From: pvyazankin Date: Fri, 5 Jun 2026 19:41:01 +0200 Subject: [PATCH 1/4] =?UTF-8?q?MILAB-6145:=20add=20:pframes.build-query-wa?= =?UTF-8?q?sm=20=E2=80=94=20opt-in=20wasm-backed=20buildQuery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a sibling library to `:pframes.build-query` that routes through the `@milaboratories/pframes-rs-wasip2` wasm component, so the SpecQueryJoinEntry tree is built by the rust source-of-truth (pframes-rs/packages/spec/src/requests/build_query/{logic,request}.rs). Public API mirrors the pure-Tengo lib exactly: `buildQuery(input) -> map`. Why a separate lib rather than swapping the impl behind the same path: `:pframes.build-query` is transitively imported by `:pframes.build-table`, which is reachable from `workflow/index.lib.tengo`. Adding the wasm import there bundles the pframes-rs-wasip2 wasm bytes into every block's `.plj.gz`, which exposes a divergence between pl-middle-layer's cached and legacy template upload paths (different content hashes for the same wasm-bundled pack — confirmed by template-cache-v3.test.ts). Keep the migration scoped: pure-Tengo entry point remains the default; callers can opt in to the wasm-backed lib explicitly. The pl-middle-layer template cache can be addressed separately. Impl notes: - Lazy-init the wasm api so renders that import the lib but never call buildQuery pay no Store-allocation cost. - Wasm-side serde requires `spec` on every linker/filter step's ColumnIdAndSpec, but BuildQuery::execute() reads only columnId. Stamp a synthetic placeholder spec on steps that omit it; matches the tolerance of the pure-Tengo lib. - Added build-query-wasm.tpl.tengo + a parallel "buildQuery (wasm lib)" loop in build-query.test.ts so every fixture is cross-checked against the SpecDriver byte-for-byte for both libs. --- .changeset/buildquery-wasm.md | 5 + .../src/pframes/build-query-wasm.lib.tengo | 105 ++++++++++++++++++ .../src/pframes/build-query-wasm.tpl.tengo | 20 ++++ .../src/pframes/build-query.test.ts | 35 ++++++ 4 files changed, 165 insertions(+) create mode 100644 .changeset/buildquery-wasm.md create mode 100644 sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo create mode 100644 tests/workflow-tengo/src/pframes/build-query-wasm.tpl.tengo 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/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..8b554854cc --- /dev/null +++ b/sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo @@ -0,0 +1,105 @@ +/** + * 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") + +// Lazy-init the wasm api so a render that imports this lib but never +// calls `buildQuery` doesn't pay the Store allocation cost. First call +// within a render opens one Store + Instance from the per-render wasm +// cache; subsequent calls in the same render reuse them. Concurrent +// renders get their own sandboxes (the wasm cache hands out a per-render +// Acquired reference to the cached Component). +api := undefined + +ensureApi := func() { + if is_undefined(api) { + api = assets.importWasm("@milaboratories/pframes-rs-wasip2:main")["milaboratories:pframes/spec"] + } + return api +} + +// 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 }) + } + respJson := ensureApi().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)), + ); + }, + ); +} From 785b3db6f762bd06000374652e0900f191980ca3 Mon Sep 17 00:00:00 2001 From: pvyazankin Date: Mon, 8 Jun 2026 15:26:59 +0200 Subject: [PATCH 2/4] MILAB-6145: reproduce wasm-in-inner-template failure (rarefaction repro) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wasm.pframes-rs-inner — an outer template that delegates to wasm.pframes-rs-inner-body via render.create. The inner template's body makes the same assets.importWasm + frame.buildQuery call that wasm.pframes-rs.tpl.tengo makes directly. Tengo-builder bundles the same wasm bytes into the same pack. The hypothesis: per-render Wasm map the workflow controller builds at compileTemplate time is scoped to the outermost rendered template only; nested renders launched via render.create see an empty Wasm map even though tengo-builder declares the dep at the pack level. The direct test passes because it makes the wasm call in the outer template's own body. The nested test should fail with "alias … is not a wasm dependency of this template". Observed symptom in rarefaction (blocks/rarefaction adapted to call :pframes.build-query-wasm in wf.body): same error message, same call shape. If this test fails as predicted, the dep propagation through nested renders is the right level to fix. --- .../src/wasm/pframes-rs-inner-body.tpl.tengo | 21 +++++++++ .../src/wasm/pframes-rs-inner.test.ts | 44 +++++++++++++++++++ .../src/wasm/pframes-rs-inner.tpl.tengo | 24 ++++++++++ 3 files changed, 89 insertions(+) create mode 100644 tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo create mode 100644 tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts create mode 100644 tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo diff --git a/tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo b/tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo new file mode 100644 index 0000000000..ea1b0fe59e --- /dev/null +++ b/tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo @@ -0,0 +1,21 @@ +// Inner template invoked via render.create from wasm.pframes-rs-inner. +// Calls assets.importWasm("@milaboratories/pframes-rs-wasip2:main") in +// its body. Identical wasm work to wasm.pframes-rs.tpl.tengo, but reached +// through a parent template — the test exists to pin down whether the +// per-render wasm-dep registry the workflow controller hands to +// plapi.loadWasm propagates through render.create into child templates, +// or only includes the top-level rendered template's wasm deps. + +self := import("@platforma-sdk/workflow-tengo:tpl") +assets := import("@platforma-sdk/workflow-tengo:assets") +json := import("json") + +self.defineOutputs(["resultJson"]) + +self.body(func(_inputs) { + api := assets.importWasm("@milaboratories/pframes-rs-wasip2:main")["milaboratories:pframes/spec"] + respJson := api.frame.buildQuery( + string(json.encode({ version: "v1", column: "abundance" })) + ) + return { resultJson: respJson } +}) diff --git a/tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts b/tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts new file mode 100644 index 0000000000..6ab971b562 --- /dev/null +++ b/tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts @@ -0,0 +1,44 @@ +import { tplTest } from "@platforma-sdk/test"; + +// Theory probe: wasm.pframes-rs.tpl.tengo calls assets.importWasm in the +// rendered template's own body and passes. wasm.pframes-rs-inner renders +// an inner template (wasm.pframes-rs-inner-body) via render.create whose +// body makes the identical wasm call — same wasm bytes, same alias, same +// pack — and the call should produce the same JSON string. +// +// Observed in rarefaction: when wasm-using code lives in a template +// reached through a nested render rather than the top-level rendered +// template, plapi.loadWasm aborts the script with "alias … is not a wasm +// dependency of this template". The per-render Wasm map the workflow +// controller builds at compileTemplate time appears to be scoped to the +// outermost template only — inner renders see an empty Wasm map even +// though tengo-builder bundles the wasm bytes into the pack. +// +// This test is the minimal reproduction in the integration suite: if it +// fails the same way rarefaction does, that confirms the bug is generic +// to render.create + wasm-using inner templates and is the right place +// to fix the dep propagation. If it passes, the bug lives somewhere in +// the block-pack → controller path that this test bypasses, and the +// repro needs to add another layer. +tplTest.concurrent( + "pframes-rs-wasip2 — wasm call inside an inner render.create template", + async ({ pl, helper, expect, skip }) => { + if (!pl.hasCapability("wasm:v1")) { + skip(); + return; + } + const result = await helper.renderTemplate( + false, + "wasm.pframes-rs-inner", + ["resultJson"], + () => ({}), + ); + + const out = await result + .computeOutput("resultJson", (a) => a?.getDataAsJson()) + .awaitStableValue(); + + expect(typeof out).toBe("string"); + expect(out as string).toEqual('{"entry":{"type":"column","column":"abundance"}}'); + }, +); diff --git a/tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo b/tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo new file mode 100644 index 0000000000..bc2608c251 --- /dev/null +++ b/tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo @@ -0,0 +1,24 @@ +// Outer template that renders wasm.pframes-rs-inner-body via render.create +// and re-exports its resultJson output. The outer template has no direct +// wasm call — the wasm import lives only inside the inner template's body. +// +// Probe template for the wasm-deps-through-nested-rendering bug observed +// when running rarefaction with a wasm-backed build-query: the wasm +// declaration tengo-builder emits at the outermost pack level doesn't +// reach the per-render deps map the workflow controller's plapi.loadWasm +// consults for child renders, so the inner template's importWasm aborts +// with "alias is not a wasm dependency of this template" even though the +// wasm bytes are bundled in the pack. + +self := import("@platforma-sdk/workflow-tengo:tpl") +assets := import("@platforma-sdk/workflow-tengo:assets") +render := import("@platforma-sdk/workflow-tengo:render") + +innerTpl := assets.importTemplate(":wasm.pframes-rs-inner-body") + +self.defineOutputs(["resultJson"]) + +self.body(func(_inputs) { + inner := render.create(innerTpl, {}) + return { resultJson: inner.output("resultJson") } +}) From c076f5f90d5a04840736be07773d0ffdda48cfd2 Mon Sep 17 00:00:00 2001 From: pvyazankin Date: Mon, 8 Jun 2026 15:35:30 +0200 Subject: [PATCH 3/4] Revert "MILAB-6145: reproduce wasm-in-inner-template failure (rarefaction repro)" This reverts commit 785b3db6f762bd06000374652e0900f191980ca3. --- .../src/wasm/pframes-rs-inner-body.tpl.tengo | 21 --------- .../src/wasm/pframes-rs-inner.test.ts | 44 ------------------- .../src/wasm/pframes-rs-inner.tpl.tengo | 24 ---------- 3 files changed, 89 deletions(-) delete mode 100644 tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo delete mode 100644 tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts delete mode 100644 tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo diff --git a/tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo b/tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo deleted file mode 100644 index ea1b0fe59e..0000000000 --- a/tests/workflow-tengo/src/wasm/pframes-rs-inner-body.tpl.tengo +++ /dev/null @@ -1,21 +0,0 @@ -// Inner template invoked via render.create from wasm.pframes-rs-inner. -// Calls assets.importWasm("@milaboratories/pframes-rs-wasip2:main") in -// its body. Identical wasm work to wasm.pframes-rs.tpl.tengo, but reached -// through a parent template — the test exists to pin down whether the -// per-render wasm-dep registry the workflow controller hands to -// plapi.loadWasm propagates through render.create into child templates, -// or only includes the top-level rendered template's wasm deps. - -self := import("@platforma-sdk/workflow-tengo:tpl") -assets := import("@platforma-sdk/workflow-tengo:assets") -json := import("json") - -self.defineOutputs(["resultJson"]) - -self.body(func(_inputs) { - api := assets.importWasm("@milaboratories/pframes-rs-wasip2:main")["milaboratories:pframes/spec"] - respJson := api.frame.buildQuery( - string(json.encode({ version: "v1", column: "abundance" })) - ) - return { resultJson: respJson } -}) diff --git a/tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts b/tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts deleted file mode 100644 index 6ab971b562..0000000000 --- a/tests/workflow-tengo/src/wasm/pframes-rs-inner.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { tplTest } from "@platforma-sdk/test"; - -// Theory probe: wasm.pframes-rs.tpl.tengo calls assets.importWasm in the -// rendered template's own body and passes. wasm.pframes-rs-inner renders -// an inner template (wasm.pframes-rs-inner-body) via render.create whose -// body makes the identical wasm call — same wasm bytes, same alias, same -// pack — and the call should produce the same JSON string. -// -// Observed in rarefaction: when wasm-using code lives in a template -// reached through a nested render rather than the top-level rendered -// template, plapi.loadWasm aborts the script with "alias … is not a wasm -// dependency of this template". The per-render Wasm map the workflow -// controller builds at compileTemplate time appears to be scoped to the -// outermost template only — inner renders see an empty Wasm map even -// though tengo-builder bundles the wasm bytes into the pack. -// -// This test is the minimal reproduction in the integration suite: if it -// fails the same way rarefaction does, that confirms the bug is generic -// to render.create + wasm-using inner templates and is the right place -// to fix the dep propagation. If it passes, the bug lives somewhere in -// the block-pack → controller path that this test bypasses, and the -// repro needs to add another layer. -tplTest.concurrent( - "pframes-rs-wasip2 — wasm call inside an inner render.create template", - async ({ pl, helper, expect, skip }) => { - if (!pl.hasCapability("wasm:v1")) { - skip(); - return; - } - const result = await helper.renderTemplate( - false, - "wasm.pframes-rs-inner", - ["resultJson"], - () => ({}), - ); - - const out = await result - .computeOutput("resultJson", (a) => a?.getDataAsJson()) - .awaitStableValue(); - - expect(typeof out).toBe("string"); - expect(out as string).toEqual('{"entry":{"type":"column","column":"abundance"}}'); - }, -); diff --git a/tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo b/tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo deleted file mode 100644 index bc2608c251..0000000000 --- a/tests/workflow-tengo/src/wasm/pframes-rs-inner.tpl.tengo +++ /dev/null @@ -1,24 +0,0 @@ -// Outer template that renders wasm.pframes-rs-inner-body via render.create -// and re-exports its resultJson output. The outer template has no direct -// wasm call — the wasm import lives only inside the inner template's body. -// -// Probe template for the wasm-deps-through-nested-rendering bug observed -// when running rarefaction with a wasm-backed build-query: the wasm -// declaration tengo-builder emits at the outermost pack level doesn't -// reach the per-render deps map the workflow controller's plapi.loadWasm -// consults for child renders, so the inner template's importWasm aborts -// with "alias is not a wasm dependency of this template" even though the -// wasm bytes are bundled in the pack. - -self := import("@platforma-sdk/workflow-tengo:tpl") -assets := import("@platforma-sdk/workflow-tengo:assets") -render := import("@platforma-sdk/workflow-tengo:render") - -innerTpl := assets.importTemplate(":wasm.pframes-rs-inner-body") - -self.defineOutputs(["resultJson"]) - -self.body(func(_inputs) { - inner := render.create(innerTpl, {}) - return { resultJson: inner.output("resultJson") } -}) From 750480e239974de0c96643d63ccb6e368f41fa23 Mon Sep 17 00:00:00 2001 From: pvyazankin Date: Mon, 8 Jun 2026 21:32:24 +0200 Subject: [PATCH 4/4] MILAB-6145: middle-layer fix + code clean up --- .../src/mutator/template/template_cache.ts | 36 +++++++++++++++++++ .../src/pframes/build-query-wasm.lib.tengo | 19 ++-------- 2 files changed, 39 insertions(+), 16 deletions(-) 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 index 8b554854cc..8517a12067 100644 --- a/sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo +++ b/sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo @@ -22,21 +22,6 @@ assets := import(":assets") maps := import(":maps") json := import("json") -// Lazy-init the wasm api so a render that imports this lib but never -// calls `buildQuery` doesn't pay the Store allocation cost. First call -// within a render opens one Store + Instance from the per-render wasm -// cache; subsequent calls in the same render reuse them. Concurrent -// renders get their own sandboxes (the wasm cache hands out a per-render -// Acquired reference to the cached Component). -api := undefined - -ensureApi := func() { - if is_undefined(api) { - api = assets.importWasm("@milaboratories/pframes-rs-wasip2:main")["milaboratories:pframes/spec"] - } - return api -} - // Wasm-side serde requires `spec` on every linker/filter step's // `ColumnIdAndSpec`, but `BuildQuery::execute()` reads only `columnId` — // `spec` content is discarded (see @@ -96,7 +81,9 @@ buildQuery := func(input) { } payload = maps.merge(input, { path: newPath }) } - respJson := ensureApi().frame.buildQuery(string(json.encode(payload))) + + wasm := assets.importWasm("@milaboratories/pframes-rs-wasip2:main")["milaboratories:pframes/spec"] + respJson := wasm.frame.buildQuery(string(json.encode(payload))) return json.decode(respJson) }