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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ output/
*.log
.prisma/
var/
.alchemy/
.prisma-composer/
517 changes: 517 additions & 0 deletions FRICTION.md

Large diffs are not rendered by default.

1,132 changes: 1,128 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// The open-chat Composer topology: storage's durable tier feeds the streams
// module (S6), a plain Postgres resource carries the app's own schema (the
// app runs its own migrations — see service.ts), and the chat compute
// service depends on both. `streamsKey` binds to the SAME platform variable
// as the streams module's own `apiKey` — one bearer key, two consumers of
// its name (spec: open-chat-port Chosen design #1). A closed root: no
// boundary argument, no return — it only provisions.
import { module } from "@prisma/composer";
import { envParam, envSecret, postgres } from "@prisma/composer-prisma-cloud";
import { storage } from "@prisma/composer-prisma-cloud/storage";
import { streams } from "@prisma/composer-prisma-cloud/streams";
import chatService from "./src/composer/service";

export default module("open-chat", ({ provision }) => {
const store = provision(storage());
const streamsModule = provision(streams(), {
deps: { store: store.store },
secrets: { apiKey: envSecret("STREAMS_API_KEY") },
});

const db = provision(postgres({ name: "database" }), { id: "database" });

provision(chatService, {
id: "chat",
deps: { db, streams: streamsModule.streams },
params: { appOrigin: envParam("APP_ORIGIN") },
secrets: {
openrouterApiKey: envSecret("OPENROUTER_API_KEY"),
betterAuthSecret: envSecret("BETTER_AUTH_SECRET"),
streamsKey: envSecret("STREAMS_API_KEY"),
stripeSecretKey: envSecret("STRIPE_SECRET_KEY"),
stripeWebhookSecret: envSecret("STRIPE_WEBHOOK_SECRET"),
},
});
});
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,26 @@
"type": "module",
"scripts": {
"dev": "bun --hot src/server/index.ts",
"dev:composer": "bun scripts/dev.ts",
"start": "bun src/server/index.ts",
"build": "bun run build:chat && bun run build:streams",
"build": "bun run build:chat && bun run build:streams && bun run build:launcher && bun run build:pack",
"build:chat": "rm -rf dist/server && bun build --target=bun --production --outdir=dist/server src/start.ts",
"build:streams": "rm -rf dist/streams && bun build --target=bun --production --outdir=dist/streams src/streams-app/index.ts",
"build:launcher": "rm -rf dist/composer && bun build --target=bun --production --outdir=dist/composer --external './dist/server/start.js' src/composer/start.ts",
"build:pack": "rm -rf dist/pack && mkdir -p dist/pack/dist && cp -R dist/composer dist/pack/dist/composer && cp -R dist/server dist/pack/dist/server",
"typecheck": "tsc --noEmit",
"test": "bun test",
"db:generate": "prisma-next contract emit",
"db:push": "prisma-next db update",
"db:init": "prisma-next db init",
"db:dev": "prisma dev --name open-chat --detach"
"db:dev": "prisma dev --name open-chat --detach",
"deploy": "bun run build && ( set -a; . ./.env; . \"${PRISMA_DEPLOY_ENV:?PRISMA_DEPLOY_ENV is required}\"; set +a; bun node_modules/.bin/prisma-composer deploy module.ts )",
"destroy": "( set -a; . ./.env; . \"${PRISMA_DEPLOY_ENV:?PRISMA_DEPLOY_ENV is required}\"; set +a; bun node_modules/.bin/prisma-composer destroy module.ts --production )"
},
"dependencies": {
"@prisma-next/postgres": "^0.13.0",
"@prisma/composer": "https://pkg.pr.new/prisma/composer/@prisma/composer@668c8b0",
"@prisma/composer-prisma-cloud": "https://pkg.pr.new/prisma/composer/@prisma/composer-prisma-cloud@668c8b0",
"@prisma/streams-local": "0.1.11",
"@prisma/streams-server": "0.1.11",
"@tanstack/db": "0.6.8",
Expand Down
10 changes: 10 additions & 0 deletions prisma-composer.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// The app's control-plane config (ADR-0017) — read ONLY by `prisma-composer
// deploy`/`destroy`, never imported by app code.
import { defineConfig } from "@prisma/composer/config";
import { nodeBuild } from "@prisma/composer/node/control";
import { prismaCloud, prismaState } from "@prisma/composer-prisma-cloud/control";

export default defineConfig({
extensions: [prismaCloud(), nodeBuild()],
state: () => prismaState(),
});
169 changes: 169 additions & 0 deletions scripts/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
#!/usr/bin/env bun
// Local dev loop for open-chat's Composer topology (S7/D2) — no cloud
// credentials. Unlike `bun run dev` (which runs src/server/index.ts
// directly, with hot reload), this boots the app through the exact same
// launcher path a deploy uses: src/composer/service.ts's compute() node,
// run() the way the deploy-printed bootstrap runs it, dynamically importing
// src/composer/start.ts once run() has resolved config/secrets. That's the
// point of this script — proving the topology's wiring locally, not fast
// iteration. `bun run dev` is untouched and remains the fast loop.
//
// Standing in for a deploy's provisioning + platform env vars:
// - Postgres: local, via open-chat's own `db:dev` (`prisma dev --detach`),
// then `prisma-next db init` (additive-only, safe to rerun).
// - Streams: the streams module's own local stand-in
// (startLocalStreamsServer from @prisma/composer-prisma-cloud/streams/testing)
// — SQLite, loopback, no auth — NOT open-chat's embedded
// @prisma/streams-local fallback (src/server/streams.ts's STREAMS_URL-unset
// path). Using the module's stand-in, and feeding its URL through the same
// COMPOSER_* config channel a deploy would, is what proves the topology's
// streams *dependency* resolves locally, not just that the app can start
// an embedded server on its own.
// - Secrets/params: written directly onto process.env in the wire format
// target/src/serializer.ts defines (COMPOSER_<ADDRESS>_<NAME>, uppercased;
// a secret slot is a pointer row naming a second env var that holds the
// real value) — the same protocol the deploy-printed bootstrap.js and
// platform env injection produce, reproduced by hand because there is no
// local-dev harness for a compute() node with real deps (see FRICTION.md).
// Built with this package's own configKey() rather than a hand-rolled
// uppercase transform, so this script cannot silently drift from the
// framework's actual key format.
//
// OPENROUTER_API_KEY is the one genuine external credential in this graph.
// This script runs without it: the secret slot still needs a non-empty value
// (service.secrets() resolves every slot eagerly — one missing/empty slot
// fails the whole call, taking sign-in and the live-tail path down with it),
// so an unset OPENROUTER_API_KEY gets a harmless local placeholder. Chat
// generation will fail against OpenRouter with that placeholder; sign-in,
// history, and the live-tail SSE path do not depend on it and still work.
// Export a real OPENROUTER_API_KEY before running this script to also
// exercise generation.
//
// Binds to 3000 by default (open-chat's own default); PORT=3100 bun run
// dev:composer picks a different one if something else already holds it.
import { randomBytes } from "node:crypto";
import { configKey } from "@prisma/composer-prisma-cloud";
import { startLocalStreamsServer } from "@prisma/composer-prisma-cloud/streams/testing";
import chatService from "../src/composer/service";

// module.ts provisions the chat service at the module root with id "chat";
// Load derives a root-scope provision's address as its bare id (no dotted
// prefix), so "chat" is the real deployment address — using it here (rather
// than "") means the env vars this script writes are exactly what a real
// deploy would write, not a look-alike local shortcut.
const ADDRESS = "chat";

// 3000 matches the app's own default (env.ts, README) — but it's only a
// default. A previous dev.ts run, another local server, or (as found while
// testing this script) an unrelated process on the operator's machine can
// already hold 3000, so this must stay overridable: PORT=3100 bun run
// dev:composer.
const DEFAULT_PORT = 3000;

function resolvePort(): number {
const override = process.env["PORT"];
if (override === undefined || override === "") return DEFAULT_PORT;
const parsed = Number(override);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`[dev:composer] PORT="${override}" is not a positive integer.`);
}
return parsed;
}

function randomHex(bytes: number) {
return randomBytes(bytes).toString("hex");
}

async function run(cmd: string[]) {
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "inherit" });
const output = await new Response(proc.stdout).text();
const code = await proc.exited;
if (code !== 0) {
throw new Error(`${cmd.join(" ")} exited with code ${code}`);
}
return output.trim();
}

console.log("[dev:composer] starting local Postgres (prisma dev)...");
const dbUrlOutput = await run([
"bunx",
"prisma",
"dev",
"--name",
"open-chat",
"--detach",
]);
const databaseUrl = dbUrlOutput.split("\n").at(-1)?.trim();
if (!databaseUrl) {
throw new Error(
`[dev:composer] could not read the database URL from "prisma dev --detach"; got:\n${dbUrlOutput}`,
);
}
console.log(`[dev:composer] Postgres ready: ${databaseUrl.replace(/:[^/:@]*@/, ":***@")}`);

console.log("[dev:composer] ensuring tables exist (prisma-next db init)...");
await run(["bunx", "prisma-next", "db", "init", "--db", databaseUrl, "-y"]);

console.log("[dev:composer] starting the streams module's local stand-in...");
const streams = await startLocalStreamsServer({ name: "open-chat-composer-dev" });
console.log(`[dev:composer] streams stand-in ready: ${streams.exports.http.url}`);

console.log("[dev:composer] building the app (bun run build:chat)...");
await run(["bun", "run", "build:chat"]);

function bindDependencyUrl(input: string, url: string) {
process.env[configKey(ADDRESS, { owner: { input }, name: "url" })] = url;
}

function bindLiteralParam(name: string, value: unknown) {
process.env[configKey(ADDRESS, { owner: "service", name })] = JSON.stringify(value);
}

/**
* Writes a secret slot's pointer row plus the platform var it points to —
* the same two-write shape deploy-time secret binding produces, just with a
* literal value here instead of a provisioned platform secret. Prefers a
* value already in this shell's env (so a developer who exports a real
* OPENROUTER_API_KEY, say, gets it used); otherwise falls back to a
* generated placeholder and warns.
*/
function bindSecret(slot: string, platformVar: string, fallback: () => string) {
const existing = process.env[platformVar];
const value = existing && existing.length > 0 ? existing : fallback();
if (!existing) {
console.warn(
`[dev:composer] ${platformVar} not set in this shell — using a local placeholder.`,
);
}
process.env[configKey(ADDRESS, { owner: "service", name: slot })] = platformVar;
process.env[platformVar] = value;
}

const port = resolvePort();
const appOrigin = `http://localhost:${port}`;

bindDependencyUrl("db", databaseUrl);
bindDependencyUrl("streams", streams.exports.http.url);
bindLiteralParam("appOrigin", appOrigin);
// The reserved `port` param — run() re-exports whatever it resolves to as
// PORT (the convention Bun.serve reads), so this is the one write that
// actually chooses which port the app binds to.
bindLiteralParam("port", port);

bindSecret("openrouterApiKey", "OPENROUTER_API_KEY", () => `local-placeholder-${randomHex(8)}`);
bindSecret("betterAuthSecret", "BETTER_AUTH_SECRET", () => randomHex(32));
bindSecret("streamsKey", "STREAMS_API_KEY", () => randomHex(16));
bindSecret("stripeSecretKey", "STRIPE_SECRET_KEY", () => `sk_test_local_${randomHex(16)}`);
bindSecret(
"stripeWebhookSecret",
"STRIPE_WEBHOOK_SECRET",
() => `whsec_local_${randomHex(16)}`,
);

process.on("SIGINT", async () => {
await streams.close();
process.exit(0);
});

console.log("[dev:composer] booting open-chat through the Composer launcher...");
await chatService.run(ADDRESS, () => import("../src/composer/start"));
42 changes: 42 additions & 0 deletions src/composer/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// The open-chat compute service: a plain Postgres and a durable streams
// server as dependencies, plus the secrets and params the app's own
// process.env surface needs (see start.ts, the launcher that maps these onto
// it). GitHub/Google OAuth are intentionally not declared here — social
// sign-in is off in the port topology (spec: open-chat-port Chosen design #8).
//
// Postgres is a plain `postgres()` dep, not `pnPostgres()`: its binding is
// `{ url }`, which is exactly what open-chat's own `src/prisma/db.ts` needs
// to build its `pg.Pool` (Better Auth shares that pool) — open-chat keeps
// running its own migrations (spec: open-chat-port Chosen design #7).
import { secret, string } from "@prisma/composer";
import node from "@prisma/composer/node";
import { compute, postgres } from "@prisma/composer-prisma-cloud";
import { durableStreams } from "@prisma/composer-prisma-cloud/streams";

export default compute({
name: "chat",
deps: {
db: postgres(),
streams: durableStreams(),
},
params: {
appOrigin: string(),
openrouterAppName: string({ default: "Open Chat Local" }),
openrouterSiteUrl: string({ default: "http://localhost:3000" }),
},
secrets: {
openrouterApiKey: secret(),
betterAuthSecret: secret(),
streamsKey: secret(),
stripeSecretKey: secret(),
stripeWebhookSecret: secret(),
},
// The directory form (node()'s dir/entry pair): `bun run build:pack`
// reproduces dist/composer/ and dist/server/ inside dist/pack/dist/, so
// dir ships both the launcher and the app's built server (plus the client
// JS/CSS/image siblings its HTML import emits) as one tree, at the same
// nesting depth start.ts's dynamic import already expects (see start.ts).
// The single-file form couldn't express "ship the launcher AND the
// server tree it imports" at all.
build: node({ module: import.meta.url, dir: "../../dist/pack", entry: "dist/composer/start.js" }),
});
55 changes: 55 additions & 0 deletions src/composer/start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// The launcher: the `node()` build adapter's `entry` (see service.ts). The
// pack-printed bootstrap dynamically imports this build's output AFTER
// main.run(address, boot) has re-keyed the platform environment
// address-free, so service.load()/config()/secrets() read it here with no
// address — the same shape as the streams module's own entrypoint
// (packages/1-prisma-cloud/2-shared-modules/streams/src/streams-entrypoint.ts
// in the framework repo), the precedent for this file.
//
// Assigns the env names open-chat's app already reads (src/server/env.ts),
// then imports the app's existing, already-built server entry unchanged —
// business logic is not touched (mission: lift the app into Composer without
// modifying it).
//
// The `node()` build adapter's directory form (service.ts) ships this
// launcher and the built server together as one copied tree. Bun resolves a
// dynamic import()'s specifier against the SOURCE file's on-disk location at
// build time (then leaves the string untouched in the bundle) — so the
// specifier below must exist on disk relative to this file both at build
// time and, unchanged, relative to wherever the bundle lands at runtime.
// "../../dist/server/start.js" satisfies both: two levels up from
// src/composer/ lands at the repo root, matching two levels up from
// dist/composer/ once built — and `bun run build:pack` (package.json)
// reproduces that exact nesting (dist/pack/dist/{composer,server}/) inside
// the tree `dir` ships, so the same string still resolves after deploy
// copies it verbatim into `bundle/`.
import service from "./service";

const { db, streams } = service.load();
const {
openrouterApiKey,
betterAuthSecret,
streamsKey,
stripeSecretKey,
stripeWebhookSecret,
} = service.secrets();
const { appOrigin, openrouterAppName, openrouterSiteUrl } = service.config();

process.env["DATABASE_URL"] = db.url;
process.env["STREAMS_URL"] = streams.url;
process.env["STREAMS_API_KEY"] = streamsKey.expose();
process.env["OPENROUTER_API_KEY"] = openrouterApiKey.expose();
process.env["BETTER_AUTH_SECRET"] = betterAuthSecret.expose();
process.env["STRIPE_SECRET_KEY"] = stripeSecretKey.expose();
process.env["STRIPE_WEBHOOK_SECRET"] = stripeWebhookSecret.expose();
process.env["APP_ORIGIN"] = appOrigin;
process.env["OPENROUTER_APP_NAME"] = openrouterAppName;
process.env["OPENROUTER_SITE_URL"] = openrouterSiteUrl;

// compute()'s own run() already exports the resolved `port` param as PORT
// (the near-universal convention), which the app's Bun.serve listener reads
// via src/server/env.ts — nothing to do here.

// @ts-expect-error — the app's built server entry ships no declaration file
// (a Bun bundle, not a TS build); imported for its side effect only.
await import("../../dist/server/start.js");
10 changes: 9 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@
],
"resolveJsonModule": true
},
"include": ["src", "prisma-next.config.ts", "prisma.compute.ts", "tests"],
"include": [
"src",
"prisma-next.config.ts",
"prisma.compute.ts",
"tests",
"module.ts",
"prisma-composer.config.ts",
"scripts"
],
// The streams app imports @prisma/streams-server, which ships raw .ts
// sources that don't pass this project's compiler settings. The entry is
// a ~20-line env bootstrap; it is verified at runtime instead.
Expand Down