Make an empty TP instance available to unit tests
diff --git a/include/perfetto/trace_processor/basic_types.h b/include/perfetto/trace_processor/basic_types.h
index a960e15..be2a8ac 100644
--- a/include/perfetto/trace_processor/basic_types.h
+++ b/include/perfetto/trace_processor/basic_types.h
@@ -215,6 +215,18 @@
   // When provided, these descriptors allow trace processor to parse custom
   // protobuf messages that are not compiled into Perfetto
   std::vector<std::string> extra_parsing_descriptors;
+
+  // When set to true, trace processor starts with a *bare* PerfettoSQL engine:
+  // only the core PerfettoSQL language (CREATE PERFETTO TABLE/FUNCTION/...,
+  // runtime functions) is available. None of the built-in trace tables (slice,
+  // thread, ...), stdlib packages, prelude, metrics or the trace_bounds table
+  // are created. This is intended for tests and tools that want a blank engine
+  // to define their own schema without name collisions or setup cost.
+  //
+  // Trace ingestion (Parse/NotifyEndOfFile) is NOT supported in this mode: the
+  // parsers rely on the built-in tables which are absent. Use it only to run
+  // SQL against tables you create yourself.
+  bool bare_sql_engine = false;
 };
 
 // Represents a dynamically typed value returned by SQL.
diff --git a/protos/perfetto/trace_processor/trace_processor.proto b/protos/perfetto/trace_processor/trace_processor.proto
index fe41e90..79f7537 100644
--- a/protos/perfetto/trace_processor/trace_processor.proto
+++ b/protos/perfetto/trace_processor/trace_processor.proto
@@ -387,6 +387,12 @@
 
   // A list of serialized FileDescriptorSet protos.
   repeated bytes extra_parsing_descriptors = 7;
+
+  // When true, starts a bare PerfettoSQL engine: only the core language is
+  // available, with no built-in tables, stdlib, prelude or metrics. Trace
+  // ingestion is unsupported in this mode; intended for running SQL against
+  // tables created by the caller.
+  optional bool bare_sql_engine = 8;
 }
 
 message RegisterSqlPackageArgs {
diff --git a/python/perfetto/trace_processor/trace_processor.descriptor b/python/perfetto/trace_processor/trace_processor.descriptor
index 16d356a..7d9e63f 100644
--- a/python/perfetto/trace_processor/trace_processor.descriptor
+++ b/python/perfetto/trace_processor/trace_processor.descriptor
Binary files differ
diff --git a/src/trace_processor/rpc/rpc.cc b/src/trace_processor/rpc/rpc.cc
index ffb3445..b3d56d8 100644
--- a/src/trace_processor/rpc/rpc.cc
+++ b/src/trace_processor/rpc/rpc.cc
@@ -543,6 +543,9 @@
     config.analyze_trace_proto_content =
         reset_trace_processor_args.analyze_trace_proto_content();
   }
+  if (reset_trace_processor_args.has_bare_sql_engine()) {
+    config.bare_sql_engine = reset_trace_processor_args.bare_sql_engine();
+  }
   if (reset_trace_processor_args.has_ftrace_drop_until_all_cpus_valid()) {
     config.soft_drop_ftrace_data_before =
         reset_trace_processor_args.ftrace_drop_until_all_cpus_valid()
diff --git a/src/trace_processor/shell/common_flags.cc b/src/trace_processor/shell/common_flags.cc
index 54259a2..ddb2291 100644
--- a/src/trace_processor/shell/common_flags.cc
+++ b/src/trace_processor/shell/common_flags.cc
@@ -118,6 +118,10 @@
   flags.push_back(BoolFlag("extra-checks", '\0',
                            "Enables additional SQL error checks.",
                            &opts->extra_checks));
+  flags.push_back(BoolFlag(
+      "bare-sql-engine", '\0',
+      "Starts with a bare PerfettoSQL engine (no built-in tables/stdlib).",
+      &opts->bare_sql_engine));
   flags.push_back(
       {/*long_name=*/"add-sql-package", /*short_name=*/'\0',
        /*has_arg=*/true, /*arg_name=*/"PATH[@PKG]",
@@ -303,6 +307,10 @@
     config.enable_extra_checks = true;
   }
 
+  if (opts.bare_sql_engine) {
+    config.bare_sql_engine = true;
+  }
+
   return config;
 }
 
diff --git a/src/trace_processor/shell/common_flags.h b/src/trace_processor/shell/common_flags.h
index 660022c..afba927 100644
--- a/src/trace_processor/shell/common_flags.h
+++ b/src/trace_processor/shell/common_flags.h
@@ -51,6 +51,7 @@
   bool dev = false;
   std::vector<std::string> dev_flags;
   bool extra_checks = false;
+  bool bare_sql_engine = false;
 
   std::vector<std::string> sql_package_paths;
   std::vector<std::string> override_sql_package_paths;
diff --git a/src/trace_processor/shell/query_subcommand.cc b/src/trace_processor/shell/query_subcommand.cc
index 525d160..a169b3c 100644
--- a/src/trace_processor/shell/query_subcommand.cc
+++ b/src/trace_processor/shell/query_subcommand.cc
@@ -203,8 +203,12 @@
   auto config = BuildConfig(*ctx.global, ctx.platform);
   ASSIGN_OR_RETURN(auto tp,
                    SetupTraceProcessor(*ctx.global, config, ctx.platform));
-  ASSIGN_OR_RETURN(auto t_load,
-                   LoadTraceFile(tp.get(), ctx.platform, trace_file));
+  // Bare mode has no trace to ingest (and the built-in tables the load path
+  // relies on don't exist), so skip loading entirely.
+  base::TimeNanos t_load{};
+  if (!ctx.global->bare_sql_engine) {
+    ASSIGN_OR_RETURN(t_load, LoadTraceFile(tp.get(), ctx.platform, trace_file));
+  }
 
   if (!query_file_.empty()) {
     if (!base::ReadFile(query_file_, &sql)) {
diff --git a/src/trace_processor/trace_processor_impl.cc b/src/trace_processor/trace_processor_impl.cc
index f13d781..8411647 100644
--- a/src/trace_processor/trace_processor_impl.cc
+++ b/src/trace_processor/trace_processor_impl.cc
@@ -529,7 +529,7 @@
   bool skip_all_sql = std::find(config_.skip_builtin_metric_paths.begin(),
                                 config_.skip_builtin_metric_paths.end(),
                                 "") != config_.skip_builtin_metric_paths.end();
-  if (!skip_all_sql) {
+  if (!skip_all_sql && !config_.bare_sql_engine) {
     // Wrap the per-metric INSERTs in one transaction; otherwise SQLite
     // implicitly commits after each statement.
     PerfettoSqlConnection::Transaction txn(engine_.get());
@@ -612,14 +612,21 @@
     }
   }
 
-  // Stage 4: prepare the connection for queries.
-  IncludeAfterEofPrelude(engine_.get());
+  // Stage 4: prepare the connection for queries. In bare mode there is no
+  // prelude registered, so skip the include (and leave the engine untouched).
+  if (!config_.bare_sql_engine) {
+    IncludeAfterEofPrelude(engine_.get());
+  }
   sqlite_objects_post_prelude_ = engine_->SqliteRegisteredObjectCount();
 
   return base::OkStatus();
 }
 
 void TraceProcessorImpl::CacheBoundsAndBuildTable() {
+  // Bare mode has no trace_bounds table (and no engine objects to back it).
+  if (config_.bare_sql_engine) {
+    return;
+  }
   uint64_t mutations = AggregatePluginBoundsMutationCount(plugins_);
   if (mutations == bounds_tables_mutations_) {
     return;
@@ -980,6 +987,13 @@
   auto connection = PerfettoSqlConnection::CreateConnectionToNewDatabase(
       storage->mutable_string_pool(), config.enable_extra_checks);
 
+  // Bare mode: return a connection with only the core PerfettoSQL language
+  // (registered by the PerfettoSqlConnection constructor). Skip built-in
+  // tables, stdlib packages, prelude, metrics and the trace_bounds table.
+  if (config.bare_sql_engine) {
+    return connection;
+  }
+
   PerfettoSqlConnection::Initializer init;
   init.static_tables.reserve(plugin_dataframes.size());
   for (const auto& df : plugin_dataframes) {
diff --git a/src/trace_processor/trace_processor_shell.cc b/src/trace_processor/trace_processor_shell.cc
index 56b48b1..9aa0981 100644
--- a/src/trace_processor/trace_processor_shell.cc
+++ b/src/trace_processor/trace_processor_shell.cc
@@ -129,6 +129,7 @@
   bool dev = false;
   std::vector<std::string> dev_flags;
   bool extra_checks = false;
+  bool bare_sql_engine = false;
   std::string export_file_path;
   std::string perf_file_path;
   bool wide = false;
@@ -320,6 +321,12 @@
  --extra-checks                       Enables additional checks which can catch
                                       more SQL errors, but which incur
                                       additional runtime overhead.
+ --bare-sql-engine                    Starts with a bare PerfettoSQL engine:
+                                      only the core language is available, with
+                                      no built-in tables, stdlib, prelude or
+                                      metrics. Trace ingestion is unsupported;
+                                      intended for running SQL against tables
+                                      you create yourself.
  -e, --export FILE                    Export the contents of trace processor
                                       into an SQLite database after running any
                                       metrics or queries specified.
@@ -422,6 +429,7 @@
   OPT_DEV,
   OPT_DEV_FLAG,
   OPT_EXTRA_CHECKS,
+  OPT_BARE_SQL_ENGINE,
   OPT_ANALYZE_TRACE_PROTO_CONTENT,
   OPT_CROP_TRACK_EVENTS,
   OPT_REGISTER_FILES_DIR,
@@ -475,6 +483,7 @@
     {"dev", no_argument, nullptr, OPT_DEV},
     {"dev-flag", required_argument, nullptr, OPT_DEV_FLAG},
     {"extra-checks", no_argument, nullptr, OPT_EXTRA_CHECKS},
+    {"bare-sql-engine", no_argument, nullptr, OPT_BARE_SQL_ENGINE},
     {"export", required_argument, nullptr, 'e'},
     {"perf-file", required_argument, nullptr, 'p'},
     {"wide", no_argument, nullptr, 'W'},
@@ -615,6 +624,11 @@
       continue;
     }
 
+    if (option == OPT_BARE_SQL_ENGINE) {
+      command_line_options.bare_sql_engine = true;
+      continue;
+    }
+
     if (option == OPT_ADD_SQL_PACKAGE) {
       command_line_options.sql_package_paths.emplace_back(optarg);
       continue;
@@ -937,6 +951,8 @@
     }
     if (options.extra_checks)
       args.emplace_back("--extra-checks");
+    if (options.bare_sql_engine)
+      args.emplace_back("--bare-sql-engine");
     for (const auto& p : options.sql_package_paths) {
       args.emplace_back("--add-sql-package");
       args.emplace_back(p);
diff --git a/ui/run-unittests b/ui/run-unittests
index 3e81748..362f05d 100755
--- a/ui/run-unittests
+++ b/ui/run-unittests
@@ -14,4 +14,4 @@
 # limitations under the License.
 
 UI_DIR="$(cd -P ${BASH_SOURCE[0]%/*}; pwd)"
-$UI_DIR/node $UI_DIR/build.mjs --only-wasm-memory64 --run-unittests "$@"
+$UI_DIR/node $UI_DIR/build.mjs --run-unittests "$@"
diff --git a/ui/src/trace_processor/engine.ts b/ui/src/trace_processor/engine.ts
index a9845a8..9218c0d 100644
--- a/ui/src/trace_processor/engine.ts
+++ b/ui/src/trace_processor/engine.ts
@@ -39,13 +39,17 @@
   // When true, the trace processor will only tokenize the trace without
   // performing a full parse. This is a performance optimization that allows for
   // a faster, albeit partial, import of the trace.
-  tokenizeOnly: boolean;
-  cropTrackEvents: boolean;
-  ingestFtraceInRawTable: boolean;
-  analyzeTraceProtoContent: boolean;
-  ftraceDropUntilAllCpusValid: boolean;
-  extraParsingDescriptors?: ReadonlyArray<Uint8Array>;
-  forceFullSort: boolean;
+  readonly tokenizeOnly?: boolean;
+  readonly cropTrackEvents?: boolean;
+  readonly ingestFtraceInRawTable?: boolean;
+  readonly analyzeTraceProtoContent?: boolean;
+  readonly ftraceDropUntilAllCpusValid?: boolean;
+  readonly extraParsingDescriptors?: ReadonlyArray<Uint8Array>;
+  readonly forceFullSort?: boolean;
+  // When true, starts a bare PerfettoSQL engine: only the core language, with no
+  // built-in tables, stdlib, prelude or metrics. Trace ingestion is unsupported
+  // in this mode; intended for running SQL against caller-created tables.
+  readonly bareSqlEngine?: boolean;
 }
 
 const QUERY_LOG_BUFFER_SIZE = 1024;
@@ -427,15 +431,18 @@
   // Updates the TraceProcessor Config. This method creates a new
   // TraceProcessor instance, so it should be called before passing any trace
   // data.
-  resetTraceProcessor({
-    tokenizeOnly,
-    cropTrackEvents,
-    ingestFtraceInRawTable,
-    analyzeTraceProtoContent,
-    ftraceDropUntilAllCpusValid,
-    extraParsingDescriptors,
-    forceFullSort,
-  }: TraceProcessorConfig): Promise<void> {
+  resetTraceProcessor(config: TraceProcessorConfig): Promise<void> {
+    const {
+      tokenizeOnly = false,
+      cropTrackEvents = false,
+      ingestFtraceInRawTable = false,
+      analyzeTraceProtoContent = false,
+      ftraceDropUntilAllCpusValid = false,
+      extraParsingDescriptors,
+      forceFullSort = false,
+      bareSqlEngine = false,
+    } = config;
+
     const asyncRes = defer<void>();
     this.pendingResetTraceProcessors.push(asyncRes);
     const rpc = protos.TraceProcessorRpc.create();
@@ -460,6 +467,7 @@
     args.extraParsingDescriptors = extraParsingDescriptors
       ? [...extraParsingDescriptors]
       : [];
+    args.bareSqlEngine = bareSqlEngine;
     this.rpcSendRequest(rpc);
     return asyncRes;
   }
diff --git a/ui/src/trace_processor/node_wasm_engine_for_testing.ts b/ui/src/trace_processor/node_wasm_engine_for_testing.ts
new file mode 100644
index 0000000..b9d2dd7
--- /dev/null
+++ b/ui/src/trace_processor/node_wasm_engine_for_testing.ts
@@ -0,0 +1,159 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// A minimal EngineBase implementation that hosts the trace_processor WASM
+// module directly in the current (Node) process — no Web Worker, no
+// MessagePort. Intended only for vitest unit tests that want to run real
+// PerfettoSQL queries against a real engine.
+//
+// The browser path (wasm_engine_proxy.ts + engine/wasm_bridge.ts) keeps the
+// WASM on a worker and bridges I/O over a MessagePort for thread isolation.
+// Here we drop that layer: the JS<>WASM RPC is synchronous, so we copy request
+// bytes into the module's HEAPU8 and read the reply back inline via the same
+// addFunction() callback the bridge uses.
+//
+// We target the 32-bit module so this works on Node versions whose V8 does not
+// support the memory64 proposal. wasm_modules' TraceProcessor32 resolves via the
+// trace_processor_32_stub indirection; vitest.config.mjs aliases that stub to
+// the real ../gen/trace_processor module (matching the production vite alias).
+
+import * as fs from 'fs';
+import * as path from 'path';
+
+// Type-only: the 32-bit stub is typed `never`, so we borrow the (identical)
+// factory type from the memory64 declarations for the module/return types.
+import type initTraceProcessorMem64 from '../gen/trace_processor_memory64';
+import {TraceProcessor32} from './wasm_modules';
+import {EngineBase} from './engine';
+
+type InitTraceProcessor = typeof initTraceProcessorMem64;
+
+// Must match REQ_BUF_SIZE in engine/wasm_bridge.ts: the C++ side allocates a
+// buffer of this size for incoming request bytes.
+const REQ_BUF_SIZE = 32 * 1024 * 1024;
+
+// Converts a 32-bit wasm pointer into a positive number usable as a HEAPU8
+// offset. memory32 pointers come across as JS numbers, but pointers above 2GB
+// arrive negative (the uint32 is reinterpreted as int32), so force unsigned.
+// See WasmBridge.wasmPtrCast in engine/wasm_bridge.ts.
+function wasmPtr32(val: number): number {
+  return val >>> 0;
+}
+
+type WasmModule = Awaited<ReturnType<InitTraceProcessor>>;
+
+// Resolves the path to the 32-bit trace_processor.wasm. Assumes it has been
+// built; if not, reading it later will fail loudly. The gen trace_processor.js
+// factory is a build artifact whose real location is <outDir>/ui/tsc/gen; the
+// matching .wasm lives in <outDir>/wasm.
+export function locateTraceProcessorWasm(): string {
+  // cwd differs by entry point: ui/ when running vitest directly (src/gen is a
+  // symlink into the build output), or <outDir> under ui/run-unittests (gen
+  // lives at ui/tsc/gen). Resolve whichever exists, then derive the sibling.
+  const realGenJs = [
+    path.resolve(process.cwd(), 'src/gen/trace_processor.js'),
+    path.resolve(process.cwd(), 'ui/tsc/gen/trace_processor.js'),
+  ].reduce<string | undefined>((found, p) => {
+    if (found) return found;
+    try {
+      return fs.realpathSync(p);
+    } catch {
+      return undefined;
+    }
+  }, undefined);
+  if (!realGenJs) {
+    throw new Error('trace_processor.js not found; build the UI wasm first.');
+  }
+  // <outDir>/ui/tsc/gen/*.js -> <outDir>/wasm/trace_processor.wasm
+  return path.resolve(
+    path.dirname(realGenJs),
+    '../../../wasm/trace_processor.wasm',
+  );
+}
+
+export class NodeWasmEngine extends EngineBase implements Disposable {
+  readonly mode = 'WASM';
+  readonly id = 'node-wasm';
+  private module!: WasmModule;
+  private reqBufferAddr = 0;
+
+  private constructor() {
+    super();
+  }
+
+  static async create(wasmPath: string): Promise<NodeWasmEngine> {
+    const engine = new NodeWasmEngine();
+    await engine.init(wasmPath);
+    return engine;
+  }
+
+  private async init(wasmPath: string): Promise<void> {
+    const file = fs.readFileSync(wasmPath);
+    // Hand the bytes directly to Emscripten so it doesn't try to fetch() them.
+    const wasmBinary = file.buffer.slice(
+      file.byteOffset,
+      file.byteOffset + file.byteLength,
+    ) as ArrayBuffer;
+    // TraceProcessor32 is the vite-aliased real 32-bit factory at runtime, but
+    // typed `never` (the stub); cast to the shared factory type.
+    const init = TraceProcessor32 as unknown as InitTraceProcessor;
+    const module = await init({
+      locateFile: (s: string) => s,
+      print: (line: string) => console.log(line),
+      printErr: (line: string) => console.warn(line),
+      onRuntimeInitialized: () => {},
+      wasmBinary,
+    });
+    const fn = module.addFunction(this.onReply.bind(this), 'vpi');
+    this.reqBufferAddr = wasmPtr32(
+      module.ccall(
+        'trace_processor_rpc_init',
+        /* return=*/ 'pointer',
+        /* args=*/ ['pointer', 'number'],
+        [fn, REQ_BUF_SIZE],
+      ),
+    );
+    this.module = module;
+  }
+
+  // Called by the C++ code (while inside trace_processor_on_rpc_request) with a
+  // pointer/size into HEAPU8 holding the response bytes.
+  private onReply(heapPtr: number, size: number) {
+    const addr = wasmPtr32(heapPtr);
+    const data = this.module.HEAPU8.slice(addr, addr + size);
+    super.onRpcResponseBytes(data);
+  }
+
+  rpcSendRequestBytes(data: Uint8Array): void {
+    // Mirrors WasmBridge.onMessage: split into REQ_BUF_SIZE-sized writes since
+    // the RPC channel is byte-oriented and handles arbitrary fragmentation.
+    let wrSize = 0;
+    while (wrSize < data.length) {
+      const sliceLen = Math.min(data.length - wrSize, REQ_BUF_SIZE);
+      this.module.HEAPU8.set(
+        data.subarray(wrSize, wrSize + sliceLen),
+        this.reqBufferAddr,
+      );
+      wrSize += sliceLen;
+      this.module.ccall(
+        'trace_processor_on_rpc_request',
+        'void',
+        ['number'],
+        [sliceLen],
+      );
+    }
+  }
+
+  [Symbol.dispose]() {}
+}
diff --git a/ui/src/trace_processor/node_wasm_engine_unittest.ts b/ui/src/trace_processor/node_wasm_engine_unittest.ts
new file mode 100644
index 0000000..586c40e
--- /dev/null
+++ b/ui/src/trace_processor/node_wasm_engine_unittest.ts
@@ -0,0 +1,86 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Runs the real trace_processor WASM in-process (via NodeWasmEngine) using the
+// bare_sql_engine config, then defines a minimal "slice" table and runs queries
+// against it. This exercises the full JS<>WASM RPC path without loading a trace.
+//
+// The test needs the trace_processor.wasm artifact to be built (e.g. via
+// ui/build.mjs or `ninja -C out/ui trace_processor_wasm`). If it isn't present
+// the whole suite is skipped rather than failing.
+
+import {NUM, STR} from './query_result';
+import {
+  NodeWasmEngine,
+  locateTraceProcessorWasm,
+} from './node_wasm_engine_for_testing';
+import {createPerfettoTable} from './sql_utils';
+
+const wasmPath = locateTraceProcessorWasm();
+
+describe('NodeWasmEngine (bare SQL engine)', () => {
+  let engine: NodeWasmEngine;
+
+  beforeAll(async () => {
+    engine = await NodeWasmEngine.create(wasmPath);
+    await engine.resetTraceProcessor({bareSqlEngine: true});
+  });
+
+  afterAll(() => {
+    engine?.[Symbol.dispose]();
+  });
+
+  test('built-in slice table is absent in bare mode', async () => {
+    const res = await engine.tryQuery('SELECT count(*) FROM slice');
+    expect(res.ok).toBe(false);
+  });
+
+  test('can create a slice table and query it', async () => {
+    await createPerfettoTable({
+      engine,
+      name: 'slice',
+      as: [
+        {id: 1, ts: 100n, dur: 10, name: 'foo'},
+        {id: 2, ts: 200n, dur: 50, name: 'bar'},
+        {id: 3, ts: 300n, dur: 5, name: 'foo'},
+      ],
+    });
+
+    const agg = await engine.query(
+      'SELECT count(*) AS cnt, sum(dur) AS total_dur FROM slice',
+    );
+    const aggRow = agg.firstRow({cnt: NUM, total_dur: NUM});
+    expect(aggRow.cnt).toBe(3);
+    expect(aggRow.total_dur).toBe(65);
+
+    const filtered = await engine.query(
+      "SELECT count(*) AS cnt FROM slice WHERE name = 'foo'",
+    );
+    expect(filtered.firstRow({cnt: NUM}).cnt).toBe(2);
+
+    const longest = await engine.query(
+      'SELECT name FROM slice ORDER BY dur DESC LIMIT 1',
+    );
+    expect(longest.firstRow({name: STR}).name).toBe('bar');
+  });
+
+  test('can iterate multiple rows', async () => {
+    const result = await engine.query('SELECT id, name FROM slice ORDER BY id');
+    const names: string[] = [];
+    for (const it = result.iter({id: NUM, name: STR}); it.valid(); it.next()) {
+      names.push(it.name);
+    }
+    expect(names).toEqual(['foo', 'bar', 'foo']);
+  });
+});
diff --git a/ui/src/trace_processor/sql_utils.ts b/ui/src/trace_processor/sql_utils.ts
index 931b2d2..c6a0da3 100644
--- a/ui/src/trace_processor/sql_utils.ts
+++ b/ui/src/trace_processor/sql_utils.ts
@@ -153,17 +153,50 @@
 
 type CreateTableArgs = {
   readonly engine: Engine;
-  readonly as: string;
+  readonly as: string | readonly Record<string, SqlValue>[];
   readonly name?: string;
 };
 
 /**
+ * Builds a SELECT statement that materializes the given rows inline, e.g. for
+ * use as a subquery or to define a table without a source trace.
+ *
+ * Each row becomes `SELECT <value> AS <col>, ...` and the rows are combined
+ * with `UNION ALL`, so the result is a self-contained query yielding exactly
+ * those rows. Column names are taken from the first row; every row is expected
+ * to have the same keys. Values are rendered via sqlValueToSqliteString().
+ *
+ * Example: [{id: 1, name: 'a'}, {id: 2, name: 'b'}] produces
+ *   SELECT 1 AS id, 'a' AS name UNION ALL SELECT 2 AS id, 'b' AS name
+ *
+ * @param rows The rows to materialize; must be non-empty.
+ * @returns The combined SELECT ... UNION ALL ... query string.
+ */
+export function rowsToSelectStatement(
+  rows: readonly Record<string, SqlValue>[],
+) {
+  const firstRow = rows[0];
+  const columnNames = Object.keys(firstRow);
+  const queryRows: string[] = [];
+  for (const row of rows) {
+    const queryCols: string[] = [];
+    for (const key of columnNames) {
+      const value = row[key];
+      queryCols.push(`${sqlValueToSqliteString(value)} AS ${key}`);
+    }
+    queryRows.push(`SELECT ${queryCols.join(', ')}`);
+  }
+
+  return queryRows.join(' UNION ALL ');
+}
+
+/**
  * Asynchronously creates a "perfetto" SQL table using the given engine and
  * returns a disposable object to handle its cleanup.
  *
  * @param args The arguments for creating the table.
  * @param args.engine The database engine to execute the query.
- * @param args.as The SQL expression to define the table.
+ * @param args.as The SQL expression to define the table or an array of row-like objects.
  * @param args.name The name of the table to be created.
  * @returns An AsyncDisposable which drops the created table when disposed.
  */
@@ -171,7 +204,13 @@
   args: CreateTableArgs,
 ): Promise<DisposableSqlEntity> {
   const {engine, as, name = makeTempName()} = args;
-  await engine.query(`CREATE PERFETTO TABLE ${name} AS ${as}`);
+  let asQuery: string;
+  if (typeof as === 'string') {
+    asQuery = as;
+  } else {
+    asQuery = rowsToSelectStatement(as);
+  }
+  await engine.query(`CREATE PERFETTO TABLE ${name} AS ${asQuery}`);
   return createDisposableSqlEntity(engine, name, 'TABLE');
 }
 
diff --git a/ui/src/trace_processor/sql_utils_unittest.ts b/ui/src/trace_processor/sql_utils_unittest.ts
index 31b006e..ce9e84b 100644
--- a/ui/src/trace_processor/sql_utils_unittest.ts
+++ b/ui/src/trace_processor/sql_utils_unittest.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {constraintsToQuerySuffix} from './sql_utils';
+import {constraintsToQuerySuffix, rowsToSelectStatement} from './sql_utils';
 
 // Clean up repeated whitespaces to allow for easier testing.
 function normalize(s: string): string {
@@ -82,3 +82,36 @@
     ),
   ).toEqual('');
 });
+
+describe('createTableQuery', () => {
+  test('single row', () => {
+    const query = rowsToSelectStatement([
+      {foo: 123, bar: 'bar', baz: 123n, qux: null},
+    ]);
+    expect(query).toBe(
+      "SELECT 123 AS foo, 'bar' AS bar, 123 AS baz, NULL AS qux",
+    );
+  });
+
+  test('multiple rows', () => {
+    const query = rowsToSelectStatement([
+      {foo: 123, bar: 'bar', baz: 123n, qux: null},
+      {foo: 456, bar: 'baz', baz: 456n, qux: null},
+    ]);
+    expect(query).toBe(
+      "SELECT 123 AS foo, 'bar' AS bar, 123 AS baz, NULL AS qux " +
+        "UNION ALL SELECT 456 AS foo, 'baz' AS bar, 456 AS baz, NULL AS qux",
+    );
+  });
+
+  test('out of order columns', () => {
+    const query = rowsToSelectStatement([
+      {foo: 123, bar: 'bar', baz: 123n, qux: null},
+      {bar: 'baz', baz: 456n, qux: null, foo: 456},
+    ]);
+    expect(query).toBe(
+      "SELECT 123 AS foo, 'bar' AS bar, 123 AS baz, NULL AS qux " +
+        "UNION ALL SELECT 456 AS foo, 'baz' AS bar, 456 AS baz, NULL AS qux",
+    );
+  });
+});
diff --git a/ui/vite.config.mjs b/ui/vite.config.mjs
index fee64aa..743e7f8 100644
--- a/ui/vite.config.mjs
+++ b/ui/vite.config.mjs
@@ -251,7 +251,7 @@
   });
 }
 
-function pluginGenRelativeImports() {
+export function pluginGenRelativeImports() {
   return {
     name: 'perfetto:gen-relative-imports',
     enforce: 'pre',
@@ -274,7 +274,7 @@
 // `export default`. In build mode @rollup/plugin-commonjs handles this; in
 // dev we transform the source: run the UMD body with a synthesised
 // `module`/`exports`, then re-export `module.exports.default` as ESM default.
-function pluginGenWasmGlueEsm() {
+export function pluginGenWasmGlueEsm() {
   return {
     name: 'perfetto:gen-wasm-glue-esm',
     enforce: 'pre',
diff --git a/ui/vitest.config.mjs b/ui/vitest.config.mjs
index 7a4df35..b9dc865 100644
--- a/ui/vitest.config.mjs
+++ b/ui/vitest.config.mjs
@@ -24,6 +24,8 @@
 import {
   pluginPerfettoPluginBarrels,
   pluginPerfettoVersion,
+  pluginGenRelativeImports,
+  pluginGenWasmGlueEsm,
 } from './vite.config.mjs';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -39,7 +41,22 @@
     // Synthesised modules under ui/src/virtual (version, plugin barrels).
     pluginPerfettoVersion(),
     pluginPerfettoPluginBarrels(),
+    // Allow tests to import the UMD wasm glue under src/gen (e.g. the node
+    // trace_processor engine shim used by *_unittest.ts running real wasm).
+    pluginGenRelativeImports(),
+    pluginGenWasmGlueEsm(),
   ],
+  resolve: {
+    alias: [
+      // Mirror the production vite alias: resolve the trace_processor_32_stub
+      // indirection to the real 32-bit gen module, so tests that use the WASM
+      // engine load the 32-bit build (works on Node without memory64 support).
+      {
+        find: /.*\/trace_processor_32_stub$/,
+        replacement: path.join(__dirname, 'src/gen/trace_processor'),
+      },
+    ],
+  },
   define: {
     'process.env.NODE_ENV': JSON.stringify('test'),
   },