Skip to content
Open
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
8 changes: 8 additions & 0 deletions .github/workflows/integration-e2e-ui.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
toolchain: stable
targets: wasm32-unknown-unknown

- uses: Swatinem/rust-cache@v2
with:
workspaces: tests/e2e-ui -> target
shared-key: e2e-ui-offline

- name: Install wasm-pack
run: cargo install wasm-pack --locked || cargo install wasm-pack

- uses: actions/setup-node@v4
with:
node-version: "22"
Expand Down Expand Up @@ -62,12 +66,16 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
toolchain: stable
targets: wasm32-unknown-unknown

- uses: Swatinem/rust-cache@v2
with:
workspaces: tests/e2e-ui -> target
shared-key: e2e-ui-browser

- name: Install wasm-pack
run: cargo install wasm-pack --locked || cargo install wasm-pack

- uses: actions/setup-node@v4
with:
node-version: "22"
Expand Down
6 changes: 6 additions & 0 deletions distributed_cli/src/client_compiler/manifest/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,8 +1083,14 @@ pub(crate) struct ManifestCommandProjection {
#[serde(deny_unknown_fields)]
pub(crate) struct ManifestCommandPureReduce {
pub(crate) fn_name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) client_module: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) client_export: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) wasm_package: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) wasm_export: String,
pub(crate) model: String,
pub(crate) key: Vec<ManifestCommandPureArg>,
pub(crate) args: Vec<ManifestCommandPureArg>,
Expand Down
106 changes: 82 additions & 24 deletions distributed_cli/src/client_compiler/render/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pub(super) fn render_commands(manifest: &ClientManifest) -> Result<String, Clien
)
})?;
let mut sections = vec!["/** GENERATED by distributed client. Do not edit. */".to_string()];
let pure_inventory = pure_function_inventory(manifest);
let pure_inventory = pure_function_inventory(manifest)?;
if !manifest.commands.is_empty() {
sections.push(
"import {\n createReplicaCommandRuntime,\n prepareReplicaCommand\n} from '@hops-ops/distributed/replica';"
Expand Down Expand Up @@ -103,52 +103,110 @@ pub(super) fn render_commands(manifest: &ClientManifest) -> Result<String, Clien
Ok(format!("{}\n", sections.join("\n\n")))
}

/// How a pure is delivered to the generated client.
#[derive(Clone, Debug, PartialEq, Eq)]
enum PureDelivery {
/// Hand-written `$lib/<module>` named export.
ClientModule { module: String, export: String },
/// wasm-pack package under `$lib`; host is generated in pures.ts.
WasmPackage { package: String, export: String },
}

/// Collect unique pure functions from command projection extensions.
fn pure_function_inventory(
manifest: &ClientManifest,
) -> Vec<(String, String, String)> {
) -> Result<Vec<(String, PureDelivery)>, ClientCompileError> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for command in &manifest.commands {
let Some(projection) = &command.extensions.projection else {
continue;
};
for reduce in &projection.pure_reduces {
if seen.insert(reduce.fn_name.clone()) {
out.push((
reduce.fn_name.clone(),
reduce.client_module.clone(),
reduce.client_export.clone(),
));
if !seen.insert(reduce.fn_name.clone()) {
continue;
}
let hand = !reduce.client_module.is_empty() || !reduce.client_export.is_empty();
let wasm = !reduce.wasm_package.is_empty() || !reduce.wasm_export.is_empty();
let delivery = if hand && !wasm {
PureDelivery::ClientModule {
module: reduce.client_module.clone(),
export: reduce.client_export.clone(),
}
} else if wasm && !hand {
PureDelivery::WasmPackage {
package: reduce.wasm_package.clone(),
export: reduce.wasm_export.clone(),
}
} else {
return Err(ClientCompileError::manifest(
"client.projection_pure_reduce",
format!(
"pure `{}` must declare either client_module+client_export or wasm_package+wasm_export",
reduce.fn_name
),
));
};
out.push((reduce.fn_name.clone(), delivery));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
Ok(out)
}

/// Generate `pures.ts` mapping pure fn ids to client module exports.
/// Generate `pures.ts` mapping pure fn ids to hosts / hand imports.
pub(super) fn render_pures(manifest: &ClientManifest) -> Result<Option<String>, ClientCompileError> {
let inventory = pure_function_inventory(manifest);
let inventory = pure_function_inventory(manifest)?;
if inventory.is_empty() {
return Ok(None);
}
let mut imports = Vec::new();
let mut sections = vec![
"/** GENERATED by distributed client. Pure functions for projection.pureReduces. */"
.to_string(),
];
let needs_wasm = inventory
.iter()
.any(|(_, d)| matches!(d, PureDelivery::WasmPackage { .. }));
if needs_wasm {
sections.push(
"import { createWasmJsonPure } from '@hops-ops/distributed/replica';".into(),
);
}
let mut entries = Vec::new();
for (index, (fn_name, module, export)) in inventory.iter().enumerate() {
let alias = format!("pure_{index}");
// From generated/<surface>/ to $lib/<module>
let rel = format!("../../{module}.js");
imports.push(format!(
"import {{ {export} as {alias} }} from '{rel}';"
));
entries.push(format!(" {}: {alias}", quoted_property(fn_name)));
let mut ready_calls = Vec::new();
for (index, (fn_name, delivery)) in inventory.iter().enumerate() {
match delivery {
PureDelivery::ClientModule { module, export } => {
let alias = format!("pure_{index}");
// From generated/<surface>/ to $lib/<module>
let rel = format!("../../{module}.js");
sections.push(format!("import {{ {export} as {alias} }} from '{rel}';"));
entries.push(format!(" {}: {alias}", quoted_property(fn_name)));
}
PureDelivery::WasmPackage { package, export } => {
let host = format!("pureHost_{index}");
// From generated/<surface>/ to $lib/<package>.js (wasm-pack entry)
let rel = format!("../../{package}.js");
sections.push(format!(
"const {host} = createWasmJsonPure({{\n load: () => import('{rel}'),\n exportName: {export_lit}\n}});",
export_lit = serde_json::to_string(export).unwrap_or_else(|_| "\"\"".into()),
));
entries.push(format!(" {}: {host}.pure", quoted_property(fn_name)));
ready_calls.push(format!(" await {host}.ensureReady();"));
}
}
}
Ok(Some(format!(
"/** GENERATED by distributed client. Pure functions for projection.pureReduces. */\n\n{}\n\nexport const PURE_FUNCTIONS = {{\n{}\n}} as const;\n",
imports.join("\n"),
sections.push(format!(
"export const PURE_FUNCTIONS = {{\n{}\n}} as const;",
entries.join(",\n")
)))
));
if !ready_calls.is_empty() {
sections.push(format!(
"/** Instantiate WASM pure hosts (no-op when none / already ready). */\nexport async function ensurePureFunctionsReady(): Promise<void> {{\n{}\n}}",
ready_calls.join("\n")
));
}
Ok(Some(format!("{}\n", sections.join("\n\n"))))
}

fn validate_command_namespaces(commands: &[ManifestCommand]) -> Result<(), ClientCompileError> {
Expand Down
11 changes: 8 additions & 3 deletions distributed_cli/src/client_compiler/render/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ pub(crate) fn render_project(
path: "commands.ts".into(),
contents: render_commands(manifest)?,
});
if let Some(pures) = super::commands::render_pures(manifest)? {
let pures = super::commands::render_pures(manifest)?;
let has_pures = pures.is_some();
if let Some(pures) = pures {
files.push(GeneratedClientFile {
path: "pures.ts".into(),
contents: pures,
Expand All @@ -66,7 +68,7 @@ pub(crate) fn render_project(
});
files.push(GeneratedClientFile {
path: "index.ts".into(),
contents: render_index(&operations),
contents: render_index(&operations, has_pures),
});
files.push(GeneratedClientFile {
path: "manifest.json".into(),
Expand Down Expand Up @@ -257,13 +259,16 @@ fn render_routes(
))
}

fn render_index(operations: &[CompiledOperation]) -> String {
fn render_index(operations: &[CompiledOperation], has_pures: bool) -> String {
let mut lines = vec![
"/** GENERATED public entrypoint. */".to_string(),
"export * from './commands.js';".into(),
"export * from './protocol.js';".into(),
"export * from './routes.js';".into(),
];
if has_pures {
lines.push("export * from './pures.js';".into());
}
for operation in operations {
let module = operation
.module_path
Expand Down
6 changes: 5 additions & 1 deletion js/src/replica/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,14 @@ export type {
ReplicaPreparedEffectKey,
ReplicaReceiptVerification
} from './commands.js';
export { createWasmJsonPure } from './projection-delta/index.js';
export type {
CreateWasmJsonPureOptions,
PreparedCommandProjection,
PreparedProjectionOperation,
ReplicaCommandProjection
ReplicaCommandProjection,
WasmJsonModule,
WasmJsonPureHost
} from './projection-delta/index.js';
export {
compareReplicaOrder,
Expand Down
6 changes: 6 additions & 0 deletions js/src/replica/projection-delta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export {
operationsFromProjectionDelta,
prepareCommandProjection
} from './resolve.js';
export { createWasmJsonPure } from './wasm-pure.js';
export type {
CreateWasmJsonPureOptions,
WasmJsonModule,
WasmJsonPureHost
} from './wasm-pure.js';
export type {
AppliedProjectionDelta,
CommandProjectionMetadata,
Expand Down
109 changes: 109 additions & 0 deletions js/src/replica/projection-delta/wasm-pure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Framework host for pure reduces backed by a wasm-bindgen module.
*
* Domain pures validate record/args and return assign fields (or null).
* This helper only: lazy-load, JSON bridge, fail-closed on any host error.
*/

import type { ReplicaValue } from '../types.js';
import type { ReplicaPureFunction } from './types.js';

/** Minimal wasm-bindgen module shape used by {@link createWasmJsonPure}. */
export type WasmJsonModule = {
default: (input?: unknown) => Promise<unknown>;
[exportName: string]: unknown;
};

export type CreateWasmJsonPureOptions = Readonly<{
/** Dynamic import of the wasm-pack module (e.g. `() => import('./pkg/x.js')`). */
load: () => Promise<WasmJsonModule>;
/**
* Named export that accepts `(recordJson: string, argsJson: string)` and
* returns a JSON object string of assign fields, or undefined/null to skip.
*/
exportName: string;
/** When true (default in browsers), start loading immediately. */
warm?: boolean;
}>;

export type WasmJsonPureHost = Readonly<{
/** Resolve when the module is instantiated (no-op on non-browser). */
ensureReady: () => Promise<void>;
/** Sync pure for `pureFunctions` / pureReduces. */
pure: ReplicaPureFunction;
}>;

function isBrowser(): boolean {
return typeof globalThis !== 'undefined' && 'window' in globalThis;
}

/**
* Build a {@link ReplicaPureFunction} that JSON-roundtrips through a WASM export.
*
* All field validation belongs in the WASM/domain pure. The host never inspects
* individual record/arg keys — missing module or throw → null (fail closed).
*/
export function createWasmJsonPure(
options: CreateWasmJsonPureOptions
): WasmJsonPureHost {
let module: WasmJsonModule | null = null;
let initPromise: Promise<void> | null = null;

const ensureReady = (): Promise<void> => {
if (!isBrowser()) {
return Promise.resolve();
}
if (module) {
return Promise.resolve();
}
if (initPromise) {
return initPromise;
}
initPromise = options
.load()
.then(async (loaded) => {
await loaded.default();
module = loaded;
})
.catch((error) => {
initPromise = null;
module = null;
throw error;
});
return initPromise;
};

if (options.warm !== false && isBrowser()) {
void ensureReady().catch(() => {
/* fail closed on first pure call */
});
}

const pure: ReplicaPureFunction = (record, args) => {
if (!module) {
return null;
}
const fn = module[options.exportName];
if (typeof fn !== 'function') {
return null;
}
try {
const out = (fn as (r: string, a: string) => string | undefined | null)(
JSON.stringify(record),
JSON.stringify(args)
);
if (out === undefined || out === null || out === '') {
return null;
}
const parsed = JSON.parse(out) as unknown;
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
return Object.freeze(parsed as Readonly<Record<string, ReplicaValue>>);
} catch {
return null;
}
};

return Object.freeze({ ensureReady, pure });
}
2 changes: 2 additions & 0 deletions src/graphql/client_manifest/projections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ pub(super) fn command_projection_extension(
fn_name: reduce.fn_name.clone(),
client_module: reduce.client_module.clone(),
client_export: reduce.client_export.clone(),
wasm_package: reduce.wasm_package.clone(),
wasm_export: reduce.wasm_export.clone(),
model: reduce.model.clone(),
key: reduce
.key
Expand Down
6 changes: 6 additions & 0 deletions src/graphql/client_manifest/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -854,8 +854,14 @@ pub struct CommandProjectionExtension {
#[serde(deny_unknown_fields)]
pub struct ClientCommandPureReduce {
pub fn_name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub client_module: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub client_export: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub wasm_package: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub wasm_export: String,
pub model: String,
pub key: Vec<ClientCommandPureArg>,
pub args: Vec<ClientCommandPureArg>,
Expand Down
Loading