Schema
Define your extension's database tables in server/schema.ts using defineSchema and defineTable from @zaflun/lumio-sdk/server. The tables back the declarative server functions (queryRows, insertRow, patchRow, deleteRow) and the bot-module ctx.db API.
Basic example
// server/schema.ts
import { defineSchema, defineTable, v } from "@zaflun/lumio-sdk/server";
export default defineSchema({
points: defineTable({
user: v.string(),
score: v.number(),
badges: v.array(v.string()).optional(),
}),
rules: defineTable({
text: v.string(),
revealed: v.boolean(),
meta: v.object({ category: v.string() }).optional(),
}),
});
Each key of defineSchema is a table name, used in functions (queryRows("points"), ctx.db.list("points")).
Column types
| Validator | PostgreSQL type | JSON value |
|---|---|---|
v.string() | TEXT | string |
v.number() | DOUBLE PRECISION | number (a JS number, so 1.5 works) |
v.boolean() | BOOLEAN | boolean |
v.array(...) | JSONB | array |
v.object({ ... }) | JSONB | object |
Every value is checked against its column type before it reaches the database. A string for a v.number() column, or null for a required column, is rejected with invalid value for column ... / column ... is not nullable.
.optional()
.optional() makes the column nullable. Without it the column is NOT NULL, and every insert must set it.
defineTable({
text: v.string(), // required, NOT NULL
score: v.number().optional(), // nullable
})
System columns
Every table gets four columns you do not declare. Every row you read carries them:
| Column | Type | Set by |
|---|---|---|
_id | UUID | generated on insert; use it for get / patch / delete |
_account_id | UUID | the account of the install that wrote the row (never from your code) |
_created_at | timestamp | insert |
_updated_at | timestamp | insert and every patch |
Names starting with _ are reserved: you cannot declare, write or filter on them.
Names and limits
| What | Limit |
|---|---|
| Table and column names | lowercase letter first, then a-z, 0-9, _; at most 40 characters; not pg_*; tables not ext_* |
| Tables per extension | 32 |
| Columns per table | 64 |
| Schema size | 64 KiB |
Storage scope
instance is the only supported scope and the default: the rows of a table belong to the account of the install that wrote them. .storage("overlay"), .storage("account") and .storage("global") are rejected at upload. See Storage scoping.
Uploading the schema
The schema is part of a version. Send the defineSchema result (JSON.stringify of the object) as data_schema when you create the version:
- REST: the
data_schemafield ofPOST /v1/developer/extensions/{id}/versions(JSON) or of the multipartPOST /v1/developer/extensions/{id}/versions/upload. - GraphQL:
dataSchemaoncreateExtensionVersion/uploadExtensionVersion.
lumio deploy does this for you: it bundles server/schema.ts (or server/schema.js / server/schema.mjs), takes its default export and sends it as data_schema with the version. An extension without a schema file uploads no data_schema. A version without data_schema declares no data: its ctx.db and server-function calls answer this extension has no data schema.
Lumio validates the schema at upload. An invalid schema (a bad name, an unsupported type or scope, too many tables) answers 422 with code INVALID_DATA_SCHEMA.
When the tables are created
Lumio creates the tables when a reviewer approves the version (into testing or published). The first install of an approved version creates them too, for example after the last uninstall removed them. Draft versions get no tables; use lumio dev to develop against a local mock.
All installs of your extension share the same tables. Each row is scoped to its account, so an install only ever sees its own account's rows.
Changing the schema
A new version may only add to the schema. Older versions that accounts have pinned keep running on the same tables, so nothing they rely on may change:
| Change | Allowed? |
|---|---|
| New table | yes |
New column with .optional() | yes |
| New required column on an existing table | no |
| Make a required column optional | yes |
| Remove a column or table from the declaration | yes: it stays in the database with its data until uninstall; a required column removed from a table you still declare becomes nullable |
| Make an optional column required | no |
| Change a column's type | no |
| Rename a column | only as remove + add: the new column must be optional, and the data is not copied |
A change that breaks these rules is rejected with 422 and code DATA_SCHEMA_NOT_ADDITIVE. The response lists every violation, for example:
{
"error": "DATA_SCHEMA_NOT_ADDITIVE",
"message": "Data schema change is not additive: points.score: type_change (applied: double precision, declared: text)",
"details": {
"violations": [
{ "table": "points", "column": "score", "rule": "type_change",
"applied": "double precision", "declared": "text" }
]
}
}
rule is type_change, new_not_null_column or not_null_tightening. The check at upload compares against the tables as they are at that moment; approval checks again, and a version that fails there is not approved and gets the list as its review notes.
Storage quota
Every extension has 1 GiB of data storage, shared by all accounts that install it. The used size counts every table of the extension with its indexes, minus the space the database can reuse. It is measured every 15 minutes.
While the extension is over the quota, insert and patch fail with 507 EXTENSION_STORAGE_QUOTA ("extension storage quota exceeded"). query, get and delete keep working, so the extension can delete rows it no longer needs to get back under the quota.
Deleted rows count once the database has vacuumed the table, usually within minutes to an hour after the delete. The next measurement after that sees the space. Writes are allowed again when the used size is at or below 90 % of the quota, so an extension that just reached the quota has to free some room before it can write again. Indexes keep their full size until they are rebuilt, so delete well below the quota rather than just under it.
Retention
Rows have no time-based expiry. They stay until the account uninstalls the extension: then that account's rows are deleted (immediately, or after the uninstall retention window), and the tables go with the last install.