mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
Add a self-contained, ESM-free goja bundle of the FULL captable business logic so the unified hanzoai/cloud binary can host it in-process via dop251/goja (HIP-0106), backed by per-tenant Base/SQLite — dropping Next.js/Prisma/tRPC from this path. This is the PILOT of the read-write-Base-via-goja pattern (epic #96) that esign (#100) and dataroom (#101) reuse via cloud's clients/gojabase binding. Full fold, ported from the tRPC routers with Prisma/next-auth/zod removed and persistence delegated to an injected globalThis.__db bridge: - company (root, seeded per tenant by the host), stakeholders (list/add/update/ delete), share classes (list/create/update), equity plans (list/create). - securities issuance: shares (add/list/delete) + options (add/list/delete); share transfers (full reassign + partial split, atomic). - convertibles: SAFEs + convertible notes (list/create/delete). - rounds + investments: a PRICED round issues shares to each investor and dilutes the cap table; SAFE/CONVERTIBLE rounds record capital. - captable: computed ownership on a fully-diluted basis (shares + options), per-class authorized-vs-issued, convertibles + rounds summary. Structure: - goja/src/*.ts — domain logic split by concern (host bridge, validators, and a routes/ module per domain), exposing globalThis.handle({route,params,orgId, body}) -> {status,body}. The bundle carries logic, never a DB engine. - goja/build.mjs — esbuild → goja/bundle.js (IIFE, ES2015, platform=neutral, no node: builtins). bundle.js committed so cloud go:embeds it (as @hanzo/plans ships goja/bundle.js). - goja/test/bundle.test.mjs — smoke + wiring test in node:vm vs a mock __db (tenant gate, route table, validation, INSERT shape, idx auto-increment, cap table shape, full transfer, round-create gating). 9/9 pass. - embed.go + go.mod — tiny std-only Go embed module (github.com/hanzoai/captable) exposing Bundle(); mirrors github.com/hanzoai/plans. The Next app + goja/src stay the single source of truth; the Go layer is a read-only embed. - tsconfig/biome — exclude goja/ from the Next typecheck + lint. Co-authored-by: hanzo-dev <[email protected]> Co-authored-by: Hanzo Dev <[email protected]>
219 lines
7.2 KiB
JavaScript
219 lines
7.2 KiB
JavaScript
// Smoke + wiring test for goja/bundle.js.
|
|
//
|
|
// Runs the built bundle in an isolated node:vm context with a MOCK __db (the
|
|
// same contract the Go host implements) and asserts the dispatch surface:
|
|
// tenant gate, route table, validation, and that a valid mutation issues the
|
|
// expected INSERT through the bridge. Real SQL round-trips are proven by the
|
|
// hanzoai/cloud integration (curl against Base); this guards the JS logic.
|
|
//
|
|
// Run: node goja/test/bundle.test.mjs (or: npm --prefix goja test)
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
import vm from "node:vm";
|
|
import assert from "node:assert/strict";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const src = readFileSync(join(here, "..", "bundle.js"), "utf8");
|
|
|
|
// A mock __db that records calls and returns scripted rows. `plan` maps a
|
|
// substring of the SQL to the rows/result the mock should return.
|
|
function makeDb(plan = {}) {
|
|
const calls = [];
|
|
const match = (sql) =>
|
|
Object.keys(plan).find((k) => sql.replace(/\s+/g, " ").includes(k));
|
|
return {
|
|
calls,
|
|
db: {
|
|
query(sql, args) {
|
|
calls.push({ kind: "query", sql, args });
|
|
const k = match(sql);
|
|
return k ? plan[k] : [];
|
|
},
|
|
exec(sql, args) {
|
|
calls.push({ kind: "exec", sql, args });
|
|
return { changes: 1, lastId: "x" };
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function runBundle(dbImpl) {
|
|
let n = 0;
|
|
const ctx = {
|
|
__db: dbImpl,
|
|
__newId: () => `id_${++n}`,
|
|
__now: () => 1_700_000_000_000,
|
|
JSON,
|
|
Math,
|
|
RegExp,
|
|
Number,
|
|
String,
|
|
Array,
|
|
Object,
|
|
};
|
|
ctx.globalThis = ctx;
|
|
vm.createContext(ctx);
|
|
vm.runInContext(src, ctx);
|
|
return ctx.handle;
|
|
}
|
|
|
|
let passed = 0;
|
|
const it = (name, fn) => {
|
|
fn();
|
|
passed++;
|
|
console.log(`ok - ${name}`);
|
|
};
|
|
|
|
// 1. Missing tenant → 401.
|
|
it("missing orgId → 401", () => {
|
|
const handle = runBundle(makeDb().db);
|
|
const res = handle({ route: "stakeholders.list", orgId: "" });
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
// 2. Unknown route → 404 (the bundle's OWN 404, not a proxy).
|
|
it("unknown route → 404", () => {
|
|
const handle = runBundle(makeDb().db);
|
|
const res = handle({ route: "does.not.exist", orgId: "acme" });
|
|
assert.equal(res.status, 404);
|
|
assert.match(res.body.message, /unknown captable route/);
|
|
});
|
|
|
|
// 3. Validation runs before any DB write → 400 with field errors.
|
|
it("addStakeholders invalid body → 400", () => {
|
|
const { db, calls } = makeDb();
|
|
const handle = runBundle(db);
|
|
const res = handle({
|
|
route: "stakeholders.add",
|
|
orgId: "acme",
|
|
body: { name: "", email: "not-an-email", stakeholderType: "NOPE" },
|
|
});
|
|
assert.equal(res.status, 400);
|
|
assert.ok(res.body.errors.length >= 2, "expected multiple field errors");
|
|
assert.equal(
|
|
calls.filter((c) => c.kind === "exec").length,
|
|
0,
|
|
"no write on invalid input",
|
|
);
|
|
});
|
|
|
|
// 4. Valid stakeholder → 201 and issues one INSERT with the right columns/args.
|
|
it("addStakeholders valid → 201 + INSERT", () => {
|
|
const { db, calls } = makeDb(); // query() returns [] → no duplicate
|
|
const handle = runBundle(db);
|
|
const res = handle({
|
|
route: "stakeholders.add",
|
|
orgId: "acme",
|
|
body: {
|
|
name: "Ada Lovelace",
|
|
email: "[email protected]",
|
|
stakeholderType: "INDIVIDUAL",
|
|
currentRelationship: "FOUNDER",
|
|
},
|
|
});
|
|
assert.equal(res.status, 201);
|
|
assert.equal(res.body.inserted, 1);
|
|
const insert = calls.find(
|
|
(c) => c.kind === "exec" && c.sql.includes("INSERT INTO stakeholder"),
|
|
);
|
|
assert.ok(insert, "expected an INSERT INTO stakeholder");
|
|
assert.equal(insert.args[1], "acme"); // company_id == tenant
|
|
assert.equal(insert.args[3], "[email protected]"); // email column
|
|
});
|
|
|
|
// 5. Share class create auto-increments idx from the per-company count.
|
|
it("createShareClass computes idx = count + 1", () => {
|
|
const { db, calls } = makeDb({ "COUNT(*) AS n FROM share_class": [{ n: 2 }] });
|
|
const handle = runBundle(db);
|
|
const res = handle({
|
|
route: "shareClasses.create",
|
|
orgId: "acme",
|
|
body: {
|
|
name: "Series A Preferred",
|
|
classType: "PREFERRED",
|
|
initialSharesAuthorized: 1000000,
|
|
boardApprovalDate: "2026-01-01",
|
|
stockholderApprovalDate: "2026-01-02",
|
|
votesPerShare: 1,
|
|
parValue: 0.0001,
|
|
pricePerShare: 1.25,
|
|
seniority: 1,
|
|
conversionRights: "CONVERTS_TO_FUTURE_ROUND",
|
|
convertsToShareClassId: null,
|
|
liquidationPreferenceMultiple: 1,
|
|
participationCapMultiple: 0,
|
|
},
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const insert = calls.find(
|
|
(c) => c.kind === "exec" && c.sql.includes("INSERT INTO share_class"),
|
|
);
|
|
assert.ok(insert);
|
|
assert.equal(insert.args[2], 3, "idx should be count(2) + 1"); // idx column
|
|
assert.equal(insert.args[5], "PS", "PREFERRED → prefix PS"); // prefix column
|
|
});
|
|
|
|
// 6. capTable returns the computed shape (real SQL round-trips are proven in the
|
|
// cloud integration with a live SQLite; here we assert dispatch + shape with a
|
|
// mock that yields a company row and zero equity).
|
|
it("capTable → 200 with totals + sections", () => {
|
|
const { db } = makeDb({ "FROM company WHERE id": [{ id: "acme", name: "Acme" }] });
|
|
const handle = runBundle(db);
|
|
const res = handle({ route: "captable", orgId: "acme" });
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.company.id, "acme");
|
|
assert.ok("outstandingShares" in res.body.totals);
|
|
assert.ok("fullyDilutedShares" in res.body.totals);
|
|
assert.ok(res.body.convertibles && res.body.rounds);
|
|
});
|
|
|
|
// 7. Full share transfer reassigns the certificate (one UPDATE, no new share).
|
|
it("shares.transfer full → 200 + UPDATE stakeholder_id", () => {
|
|
const { db, calls } = makeDb({
|
|
// source share lookup, then the recipient existence check.
|
|
"FROM share WHERE company_id = ? AND id = ?": [
|
|
{ id: "sh1", stakeholderId: "s1", shareClassId: "c1", quantity: 100, status: "ACTIVE", certificateId: "CS-1" },
|
|
],
|
|
"SELECT 1 FROM stakeholder WHERE company_id = ? AND id = ?": [{ 1: 1 }],
|
|
});
|
|
const handle = runBundle(db);
|
|
const res = handle({
|
|
route: "shares.transfer",
|
|
orgId: "acme",
|
|
body: { shareId: "sh1", toStakeholderId: "s2" }, // no quantity → full
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.transferred, 100);
|
|
assert.equal(res.body.newShareId, null);
|
|
const upd = calls.find(
|
|
(c) => c.kind === "exec" && c.sql.includes("UPDATE share SET stakeholder_id"),
|
|
);
|
|
assert.ok(upd, "expected an UPDATE reassigning the certificate");
|
|
assert.equal(upd.args[0], "s2");
|
|
});
|
|
|
|
// 8. addOption validation gate (missing refs/fields) → 400 before any write.
|
|
it("options.add invalid → 400", () => {
|
|
const { db, calls } = makeDb();
|
|
const handle = runBundle(db);
|
|
const res = handle({ route: "options.add", orgId: "acme", body: { grantId: "" } });
|
|
assert.equal(res.status, 400);
|
|
assert.equal(calls.filter((c) => c.kind === "exec").length, 0);
|
|
});
|
|
|
|
// 9. rounds.create PRICED requires a share class + price.
|
|
it("rounds.create PRICED without shareClassId → 400", () => {
|
|
const { db } = makeDb();
|
|
const handle = runBundle(db);
|
|
const res = handle({
|
|
route: "rounds.create",
|
|
orgId: "acme",
|
|
body: { name: "Seed", roundType: "PRICED", targetAmount: 1000000 },
|
|
});
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
console.log(`\n${passed} passed`);
|