Skip to content
Merged
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: 3 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"ignore": [
"node_modules",
".next",
"goja",
"dist",
"public/pdf.worker.min.js",
"./prisma/enums.ts",
Expand All @@ -25,6 +26,7 @@
"ignore": [
"node_modules",
".next",
"goja",
"dist",
"public/pdf.worker.min.js",
"./prisma/enums.ts",
Expand All @@ -43,6 +45,7 @@
"ignore": [
"node_modules",
".next",
"goja",
"dist",
"public/pdf.worker.min.js",
"./prisma/enums.ts",
Expand Down
41 changes: 41 additions & 0 deletions embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Package captable embeds the captable goja bundle so the unified hanzoai/cloud
// binary can host the cap-table business logic in-process via the dop251/goja
// engine (HIP-0106), without copying the bundle into the cloud repo.
//
// The Next.js app + the goja/src TypeScript remain the single source of truth.
// This Go file is a read-only embed:
//
// - Bundle() returns goja/bundle.js — the self-contained, ESM-free port of the
// tRPC routers (stakeholders, share classes, securities, cap-table read)
// that the Go host runs in goja, calling globalThis.handle({route,params,
// orgId,body}) per request. Persistence is the host's injected globalThis.__db
// over per-tenant Base/SQLite; there is no Prisma in this path.
//
// Mirrors github.com/hanzoai/plans exactly (the proven precedent).
package captable

import _ "embed"

// Version identifies the embedded bundle for the host's mount log. Bump on any
// change to goja/bundle.js.
const Version = "0.1.0"

//go:embed goja/bundle.js
var bundle []byte

// Bundle returns the goja bundle source (goja/bundle.js). The host compiles it
// once and runs it on each pooled goja runtime.
func Bundle() ([]byte, error) {
if len(bundle) == 0 {
return nil, errEmptyBundle
}
return bundle, nil
}

// errEmptyBundle is returned if the embed produced no bytes (a build that forgot
// to run goja/build.mjs). Fail loud rather than mount an empty bundle.
var errEmptyBundle = &bundleError{"captable: embedded goja/bundle.js is empty (run `node goja/build.mjs`)"}

type bundleError struct{ msg string }

func (e *bundleError) Error() string { return e.msg }
14 changes: 14 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// hanzoai/captable — Go embed module.
//
// This repo's PRIMARY artifact is the Next.js cap-table application. This tiny
// Go module exists ONLY so the unified hanzoai/cloud binary (HIP-0106) can embed
// the captable goja bundle (goja/bundle.js) — the self-contained, ESM-free port
// of the tRPC business logic — WITHOUT copying it into the cloud repo. Cloud
// imports github.com/hanzoai/captable and gets Bundle().
//
// std-lib only — no third-party Go deps. The TypeScript in goja/src stays the
// single source of truth; the Go layer is a read-only embed, exactly like
// github.com/hanzoai/plans.
module github.com/hanzoai/captable

go 1.26.4
1 change: 1 addition & 0 deletions goja/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
74 changes: 74 additions & 0 deletions goja/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# `goja/` — captable inside the unified cloud binary

`bundle.js` is a **self-contained, ESM-free** port of the captable tRPC business
logic (`src/trpc/routers/{stakeholder,share-class,securities}-router`), authored
in TypeScript (`src/*.ts`) and bundled by esbuild so it runs verbatim inside the
[`dop251/goja`](https://github.com/dop251/goja) JavaScript engine embedded in
[`hanzoai/cloud`](https://github.com/hanzoai/cloud) per HIP-0106. It is the pilot
of the **read-write-Base-via-goja** pattern (epic #96): the same idiom sign (#98)
and dataroom (#99) will replicate.

## Why a bundle?

goja runs ES2020 but **not** ES-module `import`/`export` and **not** `node:`
builtins — so Prisma, tRPC, next-auth and zod cannot load. Rather than rewrite
the logic in Go, the domain + validation logic lives here as TypeScript and runs
in goja; **persistence is delegated to the Go host** through an injected `__db`
bridge (per-tenant Base/SQLite). The bundle carries logic, never a DB engine.

## Host contract

The Go host (`hanzoai/cloud/clients/captable`) injects these globals before the
first dispatch, then calls `handle` per request:

```
globalThis.__db.query(sql, args) -> row objects (SELECT)
globalThis.__db.exec(sql, args) -> { changes, lastId } (INSERT/UPDATE/DELETE)
globalThis.__newId() -> collision-resistant id
globalThis.__now() -> unix milliseconds

globalThis.handle({ route, params, orgId, session, body }) -> { status, body }
```

`orgId` is the gateway-minted, **validated** tenant. The host uses it BOTH to
select the per-tenant SQLite file AND passes it here as the cap-table
`companyId`, so every query is scoped to one tenant — physically (one file per
org) and logically (the `company_id` column), mirroring the tRPC
`where: { companyId }` scope.

### Routes (the full cap-table fold)

| route(s) | HTTP (mounted by the host) |
|---|---|
| `company.get` / `company.update` | GET/PUT `/v1/captable/company` |
| `stakeholders.{list,add,update,delete}` | `/v1/captable/stakeholders[/:id]` |
| `shareClasses.{list,create,update}` | `/v1/captable/share-classes[/:id]` |
| `equityPlans.{list,create}` | `/v1/captable/equity-plans` |
| `shares.{list,add,delete,transfer}` | `/v1/captable/shares[/:id]`, `.../shares/transfer` |
| `options.{list,add,delete}` | `/v1/captable/options[/:id]` |
| `safes.{list,create,delete}` | `/v1/captable/safes[/:id]` |
| `convertibles.{list,create,delete}` | `/v1/captable/convertibles[/:id]` |
| `rounds.{list,create,get,close}` | `/v1/captable/rounds[/:id][/close]` |
| `rounds.investments.{list,add}` | `/v1/captable/rounds/:id/investments`, `/v1/captable/investments` |
| `captable` | GET `/v1/captable/summary` (computed cap table) |

Securities **issuance** (shares + options), **transfers** (full + partial), and
**rounds** (a priced round issues shares to each investor and dilutes the cap
table) are all implemented over Base. The `captable` route computes ownership on
a fully-diluted basis (shares + options), per-class authorized-vs-issued, plus a
convertibles + rounds summary.

Out of this repo's scope by design: documents / data-room (→ dataroom #101),
e-signature (→ esign #100), email. Vesting is stored (cliff/vesting years) but
schedule *computation* is a later enhancement.

## Build & test

```
node goja/build.mjs # regenerate bundle.js from src/*.ts (esbuild)
node goja/test/bundle.test.mjs # smoke + wiring test against a mock __db
```

`bundle.js` is **committed** so `hanzoai/cloud` can `go:embed` it via `embed.go`
(`Bundle()`), exactly how `@hanzo/plans` ships `goja/bundle.js`. Edit the
TypeScript, re-run the build, commit the regenerated bundle.
34 changes: 34 additions & 0 deletions goja/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Builds the self-contained, ESM-free goja bundle from the TypeScript source.
//
// Output: goja/bundle.js — an IIFE targeting ES2015 (goja supports ES2020+, but
// ES2015 keeps the output free of any runtime-feature surprises), platform
// "neutral" so esbuild injects NO node/browser globals or `node:` builtins. The
// only globals the bundle touches are the ones the Go host injects
// (__db/__newId/__now) and standard JS (JSON/Math/RegExp).
//
// Run: `node goja/build.mjs` (esbuild is the sole devDependency; see package.json)
// The built bundle.js is committed so hanzoai/cloud can go:embed it (embed.go),
// exactly how @hanzo/plans ships goja/bundle.js.

import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));

await build({
entryPoints: [join(here, "src/index.ts")],
bundle: true,
format: "iife",
target: "es2015",
platform: "neutral",
legalComments: "none",
banner: {
js:
"// @hanzo/captable — goja bundle (GENERATED by goja/build.mjs; edit src/*.ts).\n" +
"// Self-contained, ESM-free; runs in dop251/goja inside hanzoai/cloud (HIP-0106).",
},
outfile: join(here, "bundle.js"),
});

console.log("built goja/bundle.js");
Loading
Loading