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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ site/public/api/
site/public/demo.ts.static
site/public/typegres.js
site/public/typegres.d.ts
packages/
.claude/
# local clone of ~/src/exoagent (pre-existing experiment; not part of the build)
packages/exoagent/
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "packages/capnweb"]
path = packages/capnweb
url = https://github.com/ryanrasti/capnweb.git
Comment on lines +1 to +3
11,581 changes: 8,739 additions & 2,842 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"zod": "^4.3.6"
},
"dependencies": {
"camelcase": "^9.0.0"
"camelcase": "^9.0.0",
"capnweb": "file:packages/capnweb"
}
Comment on lines 89 to 92
}
1 change: 1 addition & 0 deletions packages/capnweb
Submodule capnweb added at efebdd
6 changes: 5 additions & 1 deletion src/builder/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { SqlValue } from "../types/sql-value";
import { zBool, type Bool } from "../types/bool";
import { type TsTypeOf, type Nullable, type AggregateRow } from "../types/runtime";
import { meta } from "../types/sql-value";
import { fn, expose } from "../exoeval/tool";
import { fn, expose, copyToolFields } from "../exoeval/tool";
import { isTableClass, TableBase } from "../table";
import z from "zod";
import { Values } from "./values";
Expand Down Expand Up @@ -71,6 +71,10 @@ export const hydrateRows = <R>(
const proto = Object.getPrototypeOf(shape);
return rows.map((row) => {
const instance = Object.create(proto);
// Decorator-registered @expose field markers are own symbol properties,
// which Object.create(proto) drops — carry them over so the @expose
// contract keeps working on hydrated rows.
copyToolFields(shape, instance);
for (const [k, raw] of Object.entries(row)) {
const col = shape[k];
let value: unknown;
Expand Down
62 changes: 62 additions & 0 deletions src/capnweb/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { RpcTarget } from "capnweb";
import { RpcSession, type RpcTransport } from "capnweb";
import { toRpc, type ShimStub } from "./shim";

// Test-only in-memory capnweb wiring (mirrors capnweb's own test transport):
// a client/server RpcSession pair joined by paired message queues, with the
// server's main capability run through the @expose shim.

export class InMemoryTransport implements RpcTransport {
constructor(private partner?: InMemoryTransport) {
if (partner) {
partner.partner = this;
}
}

private queue: string[] = [];
private waiter?: (() => void) | undefined;

async send(message: string): Promise<void> {
this.partner!.queue.push(message);
if (this.partner!.waiter) {
this.partner!.waiter();
this.partner!.waiter = undefined;
}
}

async receive(): Promise<string> {
while (this.queue.length === 0) {
await new Promise<void>((resolve) => {
this.waiter = resolve;
});
}
return this.queue.shift()!;
}
}

// Spin the microtask queue a bit to give in-flight messages time to be
// delivered and handled (e.g. dispose/release messages during teardown).
export const pumpMicrotasks = async () => {
for (let i = 0; i < 16; i++) {
await Promise.resolve();
}
};

export class CapnwebHarness<T extends object> {
client: RpcSession;
server: RpcSession;
stub: ShimStub<T>;

constructor(main: T) {
const clientTransport = new InMemoryTransport();
const serverTransport = new InMemoryTransport(clientTransport);
this.client = new RpcSession(clientTransport);
this.server = new RpcSession(serverTransport, toRpc(main) as RpcTarget);
this.stub = this.client.getRemoteMain() as ShimStub<T>;
}

async [Symbol.asyncDispose]() {
this.stub[Symbol.dispose]();
await pumpMicrotasks();
}
}
190 changes: 190 additions & 0 deletions src/capnweb/shim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import { RpcTarget } from "capnweb";
import z from "zod";
import { expose } from "../exoeval/tool";
import { doRpc, fromRpc, toRpc } from "./shim";
import { CapnwebHarness as Harness } from "./harness";

// --- test fixtures: a miniature @expose query-builder, no DB required ---

class Expr {
// Not @expose'd: invisible over RPC, but the server-side builder reads it freely.
constructor(readonly repr: string) {}

@expose(z.union([z.number(), z.string()]))
eq(other: number | string) {
return new Expr(`(${this.repr} = ${JSON.stringify(other)})`);
}

@expose()
upper() {
return new Expr(`upper(${this.repr})`);
}

notExposed() {
return "should not be callable over RPC";
}
}

class Row {
@expose()
id = new Expr("id");

@expose()
name = new Expr("name");

secret = "hidden";
}

class Query {
constructor(readonly wheres: Expr[] = []) {}

// eslint-disable-next-line no-restricted-syntax -- test fixture; callback arg has no zod shape
@expose.unchecked()
where(cb: (row: Row) => Expr): Query {
const result = cb(new Row());
if (result instanceof Promise) {
// Happy-path replays are synchronous; only error paths come back as (rejecting)
// promises. Pass those through so the real error propagates to the caller.
return result as unknown as Query;
}
if (!(result instanceof Expr)) {
throw new Error(`expected the callback to return a raw Expr, got: ${String(result)}`);
}
return new Query([...this.wheres, result]);
}

@expose()
sql() {
return this.wheres.map((w) => w.repr).join(" AND ");
}
}

class Api {
@expose()
query() {
return new Query();
}

@expose(z.unknown())
isQuery(q: unknown) {
return q instanceof Query;
}

@expose(z.object({ query: z.unknown(), exprs: z.array(z.instanceof(Expr)) }))
describe(shape: { query: unknown; exprs: Expr[] }) {
return {
isQuery: shape.query instanceof Query,
reprs: shape.exprs.map((e) => e.repr),
};
}

notExposed() {
return "nope";
}
}

// --- local (no-wire) behavior of the shim itself ---

describe("toRpc/fromRpc wrapping", () => {
it("exposes only @expose members", () => {
const wrapped = toRpc(new Expr("id")) as any;
expect(wrapped).toBeInstanceOf(RpcTarget);
expect(typeof wrapped.eq).toBe("function");
expect(typeof wrapped.upper).toBe("function");
expect(wrapped.notExposed).toBeUndefined();
expect(wrapped.repr).toBeUndefined();
});

it("presents @expose fields as attributes and hides the rest", () => {
const wrapped = toRpc(new Row()) as any;
expect(wrapped.id).toBeInstanceOf(RpcTarget);
expect(wrapped.secret).toBeUndefined();
// capnweb requires exposed members to look like prototype properties.
expect(Object.hasOwn(wrapped, "id")).toBe(false);
});

it("wraps recursively through method returns, arrays, objects, and promises", async () => {
const wrapped = toRpc(new Row()) as any;
const eq = wrapped.id.eq(5);
expect(eq).toBeInstanceOf(RpcTarget);
expect(fromRpc(eq)).toBeInstanceOf(Expr);

const list = toRpc([new Expr("a"), { b: new Expr("b") }]) as any[];
expect(fromRpc(list[0])).toBeInstanceOf(Expr);
expect(fromRpc((list[1] as any).b)).toBeInstanceOf(Expr);

const viaPromise = await (toRpc(Promise.resolve(new Expr("p"))) as Promise<unknown>);
expect(fromRpc(viaPromise)).toBeInstanceOf(Expr);
});

it("validates @expose schemas on wrapped calls", () => {
const wrapped = toRpc(new Expr("id")) as any;
expect(() => wrapped.eq({ not: "a number" })).toThrow(TypeError);
});

it("is stable and invertible", () => {
const expr = new Expr("id");
const wrapped = toRpc(expr);
expect(toRpc(expr)).toBe(wrapped);
expect(fromRpc(wrapped)).toBe(expr);
expect(toRpc(wrapped)).toBe(wrapped);
});
});

// --- end-to-end over capnweb RPC ---

describe("@expose over capnweb RPC", () => {
it("only @expose members are reachable", async () => {
await using h = new Harness(new Api());

expect(await h.stub.isQuery(null)).toBe(false);
await expect(h.stub.notExposed()).rejects.toThrow(TypeError);
expect(await doRpc(h.stub, (api) => (api as { notExposed?: unknown }).notExposed)).toBeUndefined();
});

it("builds a query through a replayed closure, synchronously server-side", async () => {
await using h = new Harness(new Api());

const sql = await doRpc(h.stub, (api) =>
api
.query()
.where((row) => row.id.eq(5))
.where((row) => row.name.upper().eq("ALICE"))
.sql(),
);

expect(sql).toBe('(id = 5) AND (upper(name) = "ALICE")');
});

it("rejects closure calls that fail @expose validation", async () => {
await using h = new Harness(new Api());

await expect(
doRpc(h.stub, (api) =>
api
.query()
.where((row) => row.id.eq(null as unknown as number))
.sql(),
),
).rejects.toThrow(/Invalid value/);
});

it("unwraps round-tripped capabilities back to raw objects (identity preservation)", async () => {
await using h = new Harness(new Api());

using q = await h.stub.query();
expect(await h.stub.isQuery(q)).toBe(true);
});

it("unwraps capabilities nested in containers", async () => {
await using h = new Harness(new Api());

using q = await h.stub.query();
using a = await doRpc(h.stub, (api) => api.query().where((row) => row.id.eq(1)));
// Grab raw exprs by building server-side, then hand back a mixed structure.
const described = await h.stub.describe({ query: q, exprs: [] });
expect(described).toStrictEqual({ isQuery: true, reprs: [] });
void a;
});
});
Loading
Loading