Skip to content

Commit b4a4bf1

Browse files
committed
feat(subgraphs): server-side bundler + source capture for chat authoring
1 parent 6f45ae5 commit b4a4bf1

File tree

9 files changed

+360
-5
lines changed

9 files changed

+360
-5
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@secondlayer/api": minor
3+
"@secondlayer/sdk": minor
4+
"@secondlayer/shared": patch
5+
"@secondlayer/subgraphs": patch
6+
---
7+
8+
Server-side subgraph bundler + source capture, mirroring the workflows authoring loop.
9+
10+
- **API**: new `POST /api/subgraphs/bundle` runs `bundleSubgraphCode` from `@secondlayer/bundler` and returns `{ name, version, sources, schema, handlerCode, sourceCode, bundleSize }`. `BundleSizeError → 413`, other failures → 400 with `code: "BUNDLE_FAILED"`. New `GET /api/subgraphs/:name/source` returns the original TypeScript source for deployed subgraphs, or a `readOnly` payload for rows predating the migration. `POST /api/subgraphs` now threads `sourceCode` through `deploySchema` so the original source is persisted on deploy.
11+
- **SDK**: new `subgraphs.bundle({ code })` and `subgraphs.getSource(name)` methods + `BundleSubgraphResponse` / `SubgraphSource` types.
12+
- **shared**: migration `0031_subgraph_source_code` adds `source_code TEXT NULL` to the `subgraphs` table; `registerSubgraph` upsert + `DeploySubgraphRequest` schema both accept an optional `sourceCode` field (max 1MB).
13+
- **subgraphs**: `deploySchema()` accepts `sourceCode` in its options and forwards it to `registerSubgraph`.
14+
15+
Unlocks the next wave of the chat authoring loop (read/edit/deploy/tail subgraphs in a session).

packages/api/src/routes/subgraphs.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
22
import { join } from "node:path";
3-
import { getErrorMessage } from "@secondlayer/shared";
3+
import { BundleSizeError, bundleSubgraphCode } from "@secondlayer/bundler";
4+
import { getErrorMessage, logger } from "@secondlayer/shared";
45
import { getDb, getRawClient } from "@secondlayer/shared/db";
56
import type { Subgraph } from "@secondlayer/shared/db";
67
import {
@@ -154,6 +155,7 @@ app.post("/", async (c) => {
154155
schemaName,
155156
version: parsed.data.version,
156157
handlerCode: parsed.data.handlerCode,
158+
sourceCode: parsed.data.sourceCode,
157159
});
158160

159161
await cache.refresh();
@@ -196,6 +198,84 @@ app.post("/", async (c) => {
196198
);
197199
});
198200

201+
// ── Bundle (server-side esbuild for chat authoring loop) ─────────────────
202+
//
203+
// Accepts a TypeScript subgraph source and returns the bundled handler +
204+
// extracted metadata. Called by the web chat session proxy so Vercel
205+
// serverless can skip esbuild entirely. CLI/MCP still bundle locally.
206+
// Declared before any `/:subgraphName/...` route so Hono doesn't treat
207+
// "bundle" as a subgraph name.
208+
209+
const VALID_ORIGINS = new Set(["cli", "mcp", "session"]);
210+
function readSubgraphOrigin(c: {
211+
req: { header(name: string): string | undefined };
212+
}): string {
213+
const raw = c.req.header("x-sl-origin")?.toLowerCase() ?? "unknown";
214+
return VALID_ORIGINS.has(raw) ? raw : "unknown";
215+
}
216+
217+
app.post("/bundle", async (c) => {
218+
const apiKeyId = getApiKeyId(c);
219+
const accountId = getAccountId(c);
220+
if (!apiKeyId && !accountId) {
221+
return c.json({ error: "Unauthorized" }, 401);
222+
}
223+
const origin = readSubgraphOrigin(c);
224+
225+
let body: { code?: unknown };
226+
try {
227+
body = (await c.req.json()) as { code?: unknown };
228+
} catch {
229+
throw new InvalidJSONError();
230+
}
231+
if (typeof body.code !== "string" || body.code.length === 0) {
232+
return c.json({ error: "Missing `code` string in body" }, 400);
233+
}
234+
235+
try {
236+
const bundled = await bundleSubgraphCode(body.code);
237+
const bundleSize = Buffer.byteLength(bundled.handlerCode, "utf8");
238+
logger.info("Subgraph bundled", {
239+
origin,
240+
name: bundled.name,
241+
bundleSize,
242+
ok: true,
243+
});
244+
return c.json({
245+
ok: true,
246+
name: bundled.name,
247+
version: bundled.version ?? null,
248+
description: bundled.description ?? null,
249+
sources: bundled.sources,
250+
schema: bundled.schema,
251+
handlerCode: bundled.handlerCode,
252+
sourceCode: body.code,
253+
bundleSize,
254+
});
255+
} catch (err) {
256+
if (err instanceof BundleSizeError) {
257+
logger.warn("Subgraph bundle rejected: too large", {
258+
origin,
259+
actualBytes: err.actualBytes,
260+
maxBytes: err.maxBytes,
261+
});
262+
return c.json(
263+
{
264+
ok: false,
265+
error: err.message,
266+
code: "BUNDLE_TOO_LARGE",
267+
actualBytes: err.actualBytes,
268+
maxBytes: err.maxBytes,
269+
},
270+
413,
271+
);
272+
}
273+
const message = err instanceof Error ? err.message : String(err);
274+
logger.warn("Subgraph bundle failed", { origin, error: message });
275+
return c.json({ ok: false, error: message, code: "BUNDLE_FAILED" }, 400);
276+
}
277+
});
278+
199279
// ── Reindex / backfill operations ─────────────────────────────────────
200280

201281
const MAX_CONCURRENT_OPERATIONS = 2;
@@ -688,6 +768,40 @@ app.get("/:subgraphName", async (c) => {
688768
});
689769
});
690770

771+
// ── Get source (for chat read/edit loop) ───────────────────────────────
772+
773+
app.get("/:subgraphName/source", async (c) => {
774+
const { subgraphName } = c.req.param();
775+
const accountId = getAccountId(c);
776+
const subgraph = getOwnedSubgraph(subgraphName, accountId);
777+
778+
const db = getDb();
779+
const row = await db
780+
.selectFrom("subgraphs")
781+
.select(["source_code", "updated_at"])
782+
.where("id", "=", subgraph.id)
783+
.executeTakeFirst();
784+
785+
if (!row || row.source_code === null) {
786+
return c.json({
787+
name: subgraph.name,
788+
version: subgraph.version,
789+
sourceCode: null,
790+
readOnly: true,
791+
reason: "deployed before source-capture — redeploy to enable chat edits",
792+
updatedAt: (row?.updated_at ?? subgraph.updated_at).toISOString(),
793+
});
794+
}
795+
796+
return c.json({
797+
name: subgraph.name,
798+
version: subgraph.version,
799+
sourceCode: row.source_code,
800+
readOnly: false,
801+
updatedAt: row.updated_at.toISOString(),
802+
});
803+
});
804+
691805
// ── Subgraph gaps ──────────────────────────────────────────────────────
692806

693807
app.get("/:subgraphName/gaps", async (c) => {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Hono } from "hono";
3+
import { errorHandler } from "../src/middleware/error.ts";
4+
import subgraphsRouter from "../src/routes/subgraphs.ts";
5+
6+
/**
7+
* Integration tests for POST /api/subgraphs/bundle — the server-side bundler
8+
* that powers the web chat authoring loop for subgraphs. Mirrors
9+
* workflows-bundle.test.ts: mount the router behind a tiny stand-in middleware
10+
* so `getApiKeyId(c)` succeeds without touching the real auth stack.
11+
*/
12+
13+
type TestVariables = {
14+
apiKey: { id: string };
15+
accountId: string;
16+
};
17+
18+
function buildApp() {
19+
const app = new Hono<{ Variables: TestVariables }>();
20+
app.onError(errorHandler);
21+
app.use("/subgraphs/*", async (c, next) => {
22+
c.set("apiKey", { id: "test-key-subgraphs-bundle" });
23+
c.set("accountId", "test-account-subgraphs-bundle");
24+
await next();
25+
});
26+
app.route("/subgraphs", subgraphsRouter);
27+
return app;
28+
}
29+
30+
const validSource = `
31+
import { defineSubgraph } from "@secondlayer/subgraphs";
32+
export default defineSubgraph({
33+
name: "bundle-test",
34+
sources: {
35+
swap: {
36+
type: "print_event",
37+
contractId: "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01",
38+
topic: "swap",
39+
},
40+
},
41+
schema: {
42+
swaps: {
43+
columns: {
44+
sender: { type: "principal", indexed: true },
45+
amount: { type: "uint" },
46+
},
47+
},
48+
},
49+
handlers: {
50+
swap: (_event, ctx) => {
51+
ctx.insert("swaps", { sender: ctx.tx.sender, amount: 0 });
52+
},
53+
},
54+
});
55+
`;
56+
57+
describe("POST /api/subgraphs/bundle", () => {
58+
test("happy path: valid source returns bundled handler + metadata", async () => {
59+
const app = buildApp();
60+
const res = await app.request("/subgraphs/bundle", {
61+
method: "POST",
62+
headers: { "Content-Type": "application/json" },
63+
body: JSON.stringify({ code: validSource }),
64+
});
65+
expect(res.status).toBe(200);
66+
const body = (await res.json()) as {
67+
ok: boolean;
68+
name: string;
69+
handlerCode: string;
70+
sourceCode: string;
71+
bundleSize: number;
72+
sources: Record<string, unknown>;
73+
schema: Record<string, unknown>;
74+
};
75+
expect(body.ok).toBe(true);
76+
expect(body.name).toBe("bundle-test");
77+
expect(body.handlerCode.length).toBeGreaterThan(0);
78+
expect(body.sourceCode).toBe(validSource);
79+
expect(body.bundleSize).toBeGreaterThan(0);
80+
expect(Object.keys(body.sources).length).toBeGreaterThan(0);
81+
expect(Object.keys(body.schema).length).toBeGreaterThan(0);
82+
});
83+
84+
test("missing body returns 400", async () => {
85+
const app = buildApp();
86+
const res = await app.request("/subgraphs/bundle", {
87+
method: "POST",
88+
headers: { "Content-Type": "application/json" },
89+
body: JSON.stringify({}),
90+
});
91+
expect(res.status).toBe(400);
92+
const body = (await res.json()) as { error: string };
93+
expect(body.error).toMatch(/code/);
94+
});
95+
96+
test("invalid JSON body returns 400", async () => {
97+
const app = buildApp();
98+
const res = await app.request("/subgraphs/bundle", {
99+
method: "POST",
100+
headers: { "Content-Type": "application/json" },
101+
body: "not json",
102+
});
103+
expect(res.status).toBe(400);
104+
});
105+
106+
test("oversized bundle returns 413 with actualBytes/maxBytes", async () => {
107+
const app = buildApp();
108+
const oversized = `
109+
import { defineSubgraph } from "@secondlayer/subgraphs";
110+
const BIG = ${JSON.stringify("x".repeat(5_000_000))};
111+
export default defineSubgraph({
112+
name: "too-big",
113+
sources: {
114+
swap: {
115+
type: "print_event",
116+
contractId: "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01",
117+
topic: "swap",
118+
},
119+
},
120+
schema: { swaps: { columns: { amount: { type: "uint" } } } },
121+
handlers: {
122+
swap: (_event, ctx) => {
123+
ctx.insert("swaps", { amount: BIG.length });
124+
},
125+
},
126+
});
127+
`;
128+
const res = await app.request("/subgraphs/bundle", {
129+
method: "POST",
130+
headers: { "Content-Type": "application/json" },
131+
body: JSON.stringify({ code: oversized }),
132+
});
133+
expect(res.status).toBe(413);
134+
const body = (await res.json()) as {
135+
ok: boolean;
136+
code: string;
137+
actualBytes: number;
138+
maxBytes: number;
139+
};
140+
expect(body.ok).toBe(false);
141+
expect(body.code).toBe("BUNDLE_TOO_LARGE");
142+
expect(body.actualBytes).toBeGreaterThan(body.maxBytes);
143+
});
144+
145+
test("malformed TS returns 400 with BUNDLE_FAILED", async () => {
146+
const app = buildApp();
147+
const res = await app.request("/subgraphs/bundle", {
148+
method: "POST",
149+
headers: { "Content-Type": "application/json" },
150+
body: JSON.stringify({ code: "@@@ not valid typescript !!!" }),
151+
});
152+
expect(res.status).toBe(400);
153+
const body = (await res.json()) as { ok: boolean; code: string };
154+
expect(body.ok).toBe(false);
155+
expect(body.code).toBe("BUNDLE_FAILED");
156+
});
157+
});

packages/sdk/src/subgraphs/client.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ import type {
1717
import { BaseClient } from "../base.ts";
1818
import { resolveOrderByColumn, serializeWhere } from "./serialize.ts";
1919

20+
export interface SubgraphSource {
21+
name: string;
22+
version: string;
23+
sourceCode: string | null;
24+
readOnly: boolean;
25+
reason?: string;
26+
updatedAt: string;
27+
}
28+
29+
export interface BundleSubgraphResponse {
30+
ok: true;
31+
name: string;
32+
version: string | null;
33+
description: string | null;
34+
sources: Record<string, Record<string, unknown>>;
35+
schema: Record<string, unknown>;
36+
handlerCode: string;
37+
sourceCode: string;
38+
bundleSize: number;
39+
}
40+
2041
function buildSubgraphQueryString(params: SubgraphQueryParams): string {
2142
const qs = new URLSearchParams();
2243
if (params.sort) qs.set("_sort", params.sort);
@@ -97,6 +118,22 @@ export class Subgraphs extends BaseClient {
97118
return this.request<DeploySubgraphResponse>("POST", "/api/subgraphs", data);
98119
}
99120

121+
async getSource(name: string): Promise<SubgraphSource> {
122+
return this.request<SubgraphSource>("GET", `/api/subgraphs/${name}/source`);
123+
}
124+
125+
/**
126+
* Bundle a TypeScript subgraph source on the server. Used by the web chat
127+
* authoring loop so Vercel's serverless runtime doesn't have to run esbuild.
128+
*/
129+
async bundle(data: { code: string }): Promise<BundleSubgraphResponse> {
130+
return this.request<BundleSubgraphResponse>(
131+
"POST",
132+
"/api/subgraphs/bundle",
133+
data,
134+
);
135+
}
136+
100137
async queryTable(
101138
name: string,
102139
table: string,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Kysely } from "kysely";
2+
3+
/**
4+
* Store the original TypeScript source of a subgraph alongside the bundled
5+
* handler so agents can read, diff, and edit subgraphs in chat without
6+
* re-hydrating from a file. Nullable: rows deployed before this migration
7+
* remain read-only until their next redeploy.
8+
*/
9+
export async function up(db: Kysely<any>): Promise<void> {
10+
await db.schema
11+
.alterTable("subgraphs")
12+
.addColumn("source_code", "text")
13+
.execute();
14+
}
15+
16+
export async function down(db: Kysely<any>): Promise<void> {
17+
await db.schema.alterTable("subgraphs").dropColumn("source_code").execute();
18+
}

0 commit comments

Comments
 (0)