Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/buildquery-wasm.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions lib/node/pl-middle-layer/src/mutator/template/template_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
PlTemplateOverrideV1,
PlTemplateSoftwareV1,
PlTemplateV1,
PlWasmV1,
} from "@milaboratories/pl-model-backend";
import type {
CompiledTemplateV3,
Expand All @@ -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";
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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[] = [];
Expand All @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions sdk/workflow-tengo/src/pframes/build-query-wasm.lib.tengo
Original file line number Diff line number Diff line change
@@ -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
})
20 changes: 20 additions & 0 deletions tests/workflow-tengo/src/pframes/build-query-wasm.tpl.tengo
Original file line number Diff line number Diff line change
@@ -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))
}
})
35 changes: 35 additions & 0 deletions tests/workflow-tengo/src/pframes/build-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);
},
);
}
Loading