gojabase

package
v1.799.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 12, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

README

clients/gojabase — the reusable read-write-Base goja host

gojabase is the one-and-only-one-way to run a Hanzo subsystem's self-contained JS/TS business logic (a goja bundle exposing globalThis.handle) in-process with persistence over per-tenant Base/SQLite. It is the storage-bearing sibling of clients/goja (the pure JS engine that plans/pricing use with a read-only catalog).

captable (#97) is the pilot. esign (#100) and dataroom (#101) reuse this package unchanged — it carries ZERO domain logic (no cap table, no signatures, no rooms), only the engine + the Base bridge.

What a subsystem provides

host, err := gojabase.New(gojabase.Config{
    Name:    "captable",          // names the goja host AND the data subdir
    Bundle:  bundleBytes,         // the go:embed'd bundle (globalThis.handle)
    Schema:  schemaDDL,           // per-tenant SQLite DDL (CREATE TABLE IF NOT EXISTS …)
    DataDir: deps.DataDir,        // files land at {DataDir}/{Name}/{tenantSlug}.db
    OnOpen:  seedRow,             // optional per-tenant seed, run once after migrate
})

Then, in each zip route handler, resolve the tenant from the validated principal and dispatch:

org, ok := principal.Tenant(c)          // gojabase does NOT authenticate; the leaf does
if !ok { return zip.ErrForbidden("X-Org-Id required") }
resp, err := host.Dispatch(c.Context(), org, gojabase.Request{
    Route:  "stakeholders.add",
    Params: map[string]string{"id": c.Param("id")},
    Body:   decodedJSONBody,             // any (map / slice / scalar), or nil for reads
})
c.SetHeader("Content-Type", "application/json")
return c.Bytes(resp.Status, resp.Body)   // resp is {Status int, Body json.RawMessage}

What the bundle sees (the host contract)

gojabase injects these native globals onto the runtime per dispatch, bound to the tenant's DB + a per-request transaction:

globalThis.__db.query(sql, args)  -> row objects           (SELECT; TEXT→string)
globalThis.__db.exec(sql, args)   -> { changes, lastId }    (INSERT/UPDATE/DELETE)
globalThis.__newId()              -> collision-resistant id (crypto/rand, 128-bit)
globalThis.__now()                -> unix milliseconds

globalThis.__blob.put(key, b64)   -> (only when Config.Blob is set)  store bytes off-DB
globalThis.__blob.get(key)  -> b64  (only when Config.Blob is set)  read them back

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

__blob is the OPTIONAL object-storage seam (Config.Blob, backed by the cloud VFS/S3). Use it for large binaries that must NOT bloat the per-tenant SQLite — e.g. sign's PDFs: the bundle stores the bytes with __blob.put and keeps only the returned key in a column. Keys are tenant-scoped by the host ({Name}/{TenantSegment}), so a bundle can never reach another tenant's blobs. Payloads cross as base64.

orgId is the tenant passed to Dispatch — the bundle uses it to scope rows (defence in depth on top of the per-tenant file). args is a positional array bound to ? placeholders. The Go host owns the schema (migrations); the bundle issues SQL against it — column names are the coupling, so keep them in sync.

Guarantees

  • Per-tenant isolation — one SQLite file per org ({DataDir}/{Name}/{TenantSegment}.db), opened lazily, migrated once, pooled (LRU-capped + idle-evicted). TenantSegment is an injective, traversal-safe encoding (lowercased unpadded base32 of the raw org bytes), so DISTINCT orgs — including case/separator variants like Acme/acme and a b/a_b — NEVER share a file, and the [a-z2-7] segment can never traverse the data tree.
  • Atomicity — each Dispatch runs handle inside ONE transaction that commits iff the response status < 400 and handle did not throw; otherwise it rolls back. Multi-statement mutations (e.g. a share transfer: shrink source + insert target) are all-or-nothing for free, and a validation 400 leaves the DB untouched. MaxOpenConns(1) serializes writes per tenant.
  • No JS-visible transaction API — the per-request transaction removes the need for one; bundles just call query/exec.

Leaf wiring (register)

Register the leaf and blank-import it in subsystems/subsystems.go. It mounts under the mount-all default (empty CLOUD_ENABLE) — the captable/sign/dataroom folds are NOT staged (their standalone apps are retired/empty, so the one binary is authoritative from first write):

func init() { cloud.RegisterWithShutdown("captable", 133, cloud.Typed(Mount), shutdown) }

See clients/captable for the complete reference leaf (schema, seed, routes).

Documentation

Overview

Package gojabase is the REUSABLE read-write-Base goja host: it runs a Hanzo subsystem's self-contained JS bundle (globalThis.handle) inside dop251/goja and gives that bundle PERSISTENCE over per-tenant Base/SQLite, injected as native host globals. It is the storage-bearing sibling of clients/goja (which is the pure JS engine that plans/pricing use with a read-only catalog).

ONE-AND-ONLY-ONE-WAY. Any subsystem that wants "run my TS business logic in goja, persist to Base per tenant" uses THIS package: pass a Bundle + a per- tenant Schema (DDL) + the DataDir, get a Host, and Dispatch(ctx, tenant, req). captable is the pilot; esign (#100) and dataroom (#101) reuse it unchanged — the binding carries ZERO domain logic (no cap-table, no signatures, no rooms), only the engine + the Base bridge.

Host contract (what the binding injects onto the runtime per dispatch)

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

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

`orgId` is the tenant the caller passed to Dispatch (the validated cloud principal's org); the binding uses it BOTH to select the per-tenant SQLite file AND passes it to handle so a bundle can scope rows by it (defence in depth). `body` is the decoded request body; `params`/`query` are string maps.

Atomicity

Each Dispatch runs handle inside ONE SQLite transaction on the tenant's DB (deferred BEGIN → read-only until the first write). The transaction COMMITS iff handle returns status < 400 and does not throw; otherwise it ROLLS BACK. So a request is all-or-nothing without any JS-visible transaction API — a multi-statement mutation (e.g. a share transfer: delete source + insert target) is atomic for free, and a validation 400 leaves the DB untouched.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func TenantSegment added in v1.786.216

func TenantSegment(tenant string) string

TenantSegment maps a tenant/org key to an INJECTIVE, traversal-safe single path segment: lowercased unpadded base32 of the RAW org bytes.

This is deliberately NOT a "slug": lowercasing + folding illegal bytes to '_' (the previous slugify) is NON-injective and collapses DISTINCT owners onto ONE physical store — a cross-tenant break. principal.Org returns the org VERBATIM for exactly that reason (folding "Acme"/"acme" or "a b"/"a_b" into one bucket is itself a tenant break); this encoder preserves that distinction all the way to the on-disk name. base32 of the raw bytes is a bijection with its output, so distinct orgs ALWAYS map to distinct segments: "Acme", "acme", "a b" and "a_b" each land on their OWN file (the previous slugify collapsed all four onto two).

The [a-z2-7] output contains no path separator and can never equal "." or ".." (those need chars outside the alphabet), so a segment can never traverse the data tree or an object-store key prefix. Empty tenant → "" (callers reject it).

It is the SINGLE encoding every gojabase tenant-keyed path uses — the per-tenant SQLite filename here AND the dataroom object-store key prefix — so a tenant maps to exactly ONE physical identity everywhere.

Types

type BlobStore added in v1.786.216

type BlobStore interface {
	Put(ctx context.Context, key string, payload []byte) error
	Get(ctx context.Context, key string) ([]byte, error)
}

BlobStore is the ONE object-storage seam a bundle uses to persist large binary payloads OUTSIDE its per-tenant SQLite (e.g. sign's PDFs — a 32 MiB base64 blob in a TEXT column would bloat the tenant DB and get copied on every read). The cloud VFS/S3 data plane (deps.VFS) satisfies it, exactly as clients/dataroom already uses it for document bytes. Keys are opaque; gojabase tenant-scopes them.

type Config

type Config struct {
	// Name identifies the subsystem ("captable", "esign", "dataroom"). It names
	// the goja host AND the per-tenant data subdir ({DataDir}/{Name}/).
	Name string
	// Bundle is the self-contained goja bundle exposing globalThis.handle.
	Bundle []byte
	// Schema is the per-tenant SQLite DDL, run (idempotently — use
	// CREATE TABLE IF NOT EXISTS) on every tenant DB when it first opens.
	Schema string
	// DataDir is the deployment data root; per-tenant files land at
	// {DataDir}/{Name}/{TenantSegment(tenant)}.db (injective, traversal-safe
	// base32 of the raw org bytes — see gojabase/store.go TenantSegment).
	DataDir string
	// OnOpen is an optional per-tenant seed hook run ONCE after migration (e.g.
	// captable seeds the tenant's company row). It runs outside the per-request
	// transaction. May be nil.
	OnOpen func(ctx context.Context, tenant string, db *sql.DB) error
	// HostFns are OPTIONAL extra native host globals injected onto the runtime on
	// every Dispatch, ALONGSIDE __db/__newId/__now — for capabilities goja cannot
	// provide that a subsystem implements in Go (e.g. esign injects __pdf =
	// { stamp, sign } for PDF rendering + x509/PKCS#7 signing). Values are Go
	// funcs or map[string]any of Go funcs (goja exposes them as callable JS). They
	// are process-global (set once at New), not per-tenant; the binding stays
	// domain-free. May be nil. A key MUST NOT collide with __db/__newId/__now/__blob.
	HostFns map[string]any
	// Blob is the OPTIONAL object-storage seam (see BlobStore). When set, gojabase
	// injects globalThis.__blob = { put(key, b64), get(key) -> b64 } on every
	// Dispatch, bound to the tenant: keys are prefixed with {Name}/{TenantSegment}
	// so a bundle can NEVER address another tenant's blob. Payloads cross as base64
	// strings (goja-friendly); gojabase decodes/encodes at the boundary so the
	// bundle never handles raw bytes. nil ⇒ no __blob is injected. This is the ONE
	// way a bundle keeps big binaries out of its per-tenant SQLite.
	Blob BlobStore
}

Config configures a Host.

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host is a compiled bundle + its per-tenant Base stores. Safe for concurrent use.

func New

func New(cfg Config) (*Host, error)

New compiles the bundle (via clients/goja) and prepares the per-tenant store manager. It does NOT open any tenant DB — those open lazily on first Dispatch.

func (*Host) Close

func (h *Host) Close() error

Close closes every open tenant DB and drops the goja engine. Idempotent.

func (*Host) Dispatch

func (h *Host) Dispatch(ctx context.Context, tenant string, req Request) (*Response, error)

Dispatch resolves the tenant's Base/SQLite store, opens a per-request transaction, injects the RW-Base host globals bound to it, and calls globalThis.handle. It commits on a <400 non-throwing response and rolls back otherwise. tenant MUST be a validated principal's org (the caller resolves it, e.g. via clients/principal.Org) — the binding does not itself authenticate.

type Request

type Request struct {
	Route  string
	Params map[string]string
	Query  map[string]string
	Body   any
}

Request is the dispatch envelope. The binding adds the tenant (as orgId) and the Base bridge; the caller supplies route/params/query/body.

type Response

type Response = goja.Response

Response mirrors the JS-side {status, body} (reused from clients/goja).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL