@voilabs/mta
v0.2.0
Published
A gateway-compatible module API SDK for building multi-module tenant services.
Maintainers
Readme
@voilabs/mta
Tiny, type-safe SDK for building voi gateway-compatible module APIs.
It is intentionally service-oriented: one HTTP API can expose many independent
modules on the same port. Each module has its own key, route prefix, permissions,
layout contract and data contract. Your tables are plain Drizzle
pgTables — they type every query and drive the runtime auto-migration.
Install
bun add @voilabs/mta drizzle-orm elysiaScaffold A New API
voi init commerce-api --modules products,ordersThe scaffold includes a Drizzle schema, a module catalog, runtime setup and signed internal routes. Domain routes are ordinary Elysia routes you write yourself.
Minimal Usage
voi.server() returns a fully typed Elysia app with everything wired
in — signed system routes, tenant/module context and, for a single-module
service, the automatic module guard. Handlers receive a typed tenant,
tenantDb (a Drizzle client bound to your schema), module, moduleKey and
moduleEnv. No : any, no generated CRUD — you query Drizzle directly.
import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import {
Core,
createDrizzleTenantDbPool,
defineCrudModule,
defineModules,
defineService
} from "@voilabs/mta";
import { desc } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { t } from "elysia";
const service = defineService({
key: "products-api",
title: "Products API",
protocolVersion: 2
});
// The Drizzle table is the single source of truth: it types every query and is
// what the tenant DB pool auto-creates/migrates on first use.
export const products = pgTable("products", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
price_cents: integer("price_cents").notNull().default(0),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
});
const schema = { products };
const modules = defineModules([
defineCrudModule({
key: "products",
title: "Products",
path: "/products",
tables: [products],
fields: [
{ name: "name", label: "Name", type: "text", required: true, listVisible: true },
{ name: "price_cents", label: "Price", type: "currency", required: true, listVisible: true }
]
})
]);
const tenantDb = createDrizzleTenantDbPool({ schema, drizzle });
const voi = new Core({
service,
modules,
resolveTenantDb: tenantDb.resolveTenantDb
});
const app = voi.server().group("/products", (group) =>
group
.get("/", ({ tenantDb }) =>
tenantDb.select().from(products).orderBy(desc(products.created_at)))
.post(
"/",
async ({ tenantDb, body, status }) => {
const [row] = await tenantDb.insert(products).values(body).returning();
return status(201, row);
},
{ body: t.Object({ name: t.String({ minLength: 1 }), price_cents: t.Integer() }) }
)
);
app.listen(4001);Unsigned direct access returns 404. The gateway discovers modules through a
signed internal endpoint:
GET /__voi/manifestRuntime module keys are pushed through:
POST /__voi/module-keyThe first key push uses trust-on-first-use so the module service can boot without
any .env secret. After a module key exists, key rotation and tenant requests
must be signed with the current per-module key.
Manifest Depth
A module definition can describe:
- navigation and screens
- table, detail and form fields
- permissions and capabilities
- default limits
- data tenancy and migrations
- JSON-schema-like input/output contracts
- route contracts
- action placement, confirmation and intent
That makes the gateway/admin UI able to reason about a module without hardcoding its screens.
Helpers
Core is the entry point and voi.server() is the ergonomic way to build
the app — a typed Elysia instance with the system routes and tenant/module
context already mounted, so handlers get tenant, tenantDb and moduleEnv for
free:
const app = voi.server().get("/products/health", ({ tenant, moduleEnv }) => ({
ok: true,
tenant: tenant.slug,
hasRedis: Boolean(moduleEnv.PRODUCTS_REDIS_URL)
}));createDrizzleTenantDbPool({ schema, drizzle }) is the package-owned tenant pool. It hands
each request a PostgresJsDatabase<typeof schema> (a Drizzle client) and runs the
runtime auto-migration. Module APIs never define their own getTenantDb.
Passing your app's own drizzle import keeps table/query/client types aligned in
local linked package setups where TypeScript may otherwise see two Drizzle
installations.
voi.requireModule("orders") is a typed beforeHandle guard for
multi-module services (see below).
If you need full control over the Elysia chain, the building blocks are also
exposed: voi.use mounts the signed system endpoints (e.g.
/__voi/manifest), voi.derive resolves tenant/module context,
and voi.macro adds { requireModule: "..." } to route groups. server()
is use + derive composed. No domain route is generated automatically.
defineCrudModule turns a small field list into a complete manifest — navigation,
permissions, capabilities, list/get/create/update/delete route contracts,
create/update/output schemas, and table/form/detail views. Pass tables (your
Drizzle tables) so the manifest advertises the module's migrations.
Single-Module Services
When a service registers exactly one module, the SDK enforces that module on
every route automatically. You never write a requireModule guard — any
request whose module context does not match the only module gets 404, while
signed /__voi/* system routes stay reachable.
// Single module → just add routes to server(); the guard is implicit.
const app = voi.server().group("/products", (group) =>
group.get("/", ({ tenantDb }) => tenantDb.select().from(products))
);Services with two or more modules scope each group to a module with the typed
requireModule guard, since the SDK cannot infer which route belongs to which
module:
const app = voi
.server()
.guard({ beforeHandle: voi.requireModule("products") }, (app) =>
app.group("/products", (group) =>
group.get("/", ({ tenantDb }) => tenantDb.select().from(products))))
.guard({ beforeHandle: voi.requireModule("orders") }, (app) =>
app.group("/orders", (group) =>
group.get("/", ({ tenantDb }) => tenantDb.select().from(orders))));Auto Migrate
The Drizzle schema you pass to createDrizzleTenantDbPool({ schema, drizzle }) is the
single source of truth. On the first request per tenant database the pool creates
the database if missing and runs idempotent CREATE TABLE / ADD COLUMN /
CREATE INDEX statements derived from the table metadata, then hands the handler
a typed Drizzle client:
const tenantDb = createDrizzleTenantDbPool({
schema: { products, orders },
drizzle,
autoMigrate: true, // default; set false to manage migrations yourself
createDatabase: true // default; set false if the database always exists
});Module Env
Modules can declare runtime values they need from the host system:
defineCrudModule({
key: "products",
title: "Products",
path: "/products",
env: {
variables: [
{
key: "PRODUCTS_REDIS_URL",
label: "Products Redis URL",
type: "url",
sensitive: true,
required: true
}
]
},
fields: []
});The main app/admin can collect those values, store them encrypted, and forward
them to the module through signed proxy requests. Route handlers receive them as
moduleEnv in the request context.
Multi-Module Example
The package includes a runnable example that serves products and orders from
one API process:
bun run examples/multi-module-api.tsBoth modules publish their own manifest layout, route contracts, permissions and
schemas through the same signed GET /__voi/manifest endpoint.
Startup Validation
Core validates the manifest at startup and fails fast for common mistakes:
- duplicate module keys
- duplicate module paths
- invalid service/module keys
- route permissions that do not exist
- UI views pointing to missing resources
- duplicate field, resource, route or action keys
