Modules
Data and files
Data
A table holds JSON documents. Fields declared in migrate add what a document store lacks; undeclared fields are stored as they come.
ctx.db.migrate("products", 2, |t| {
t.text("sku", { unique: true, required: true }); // a unique index; a clash is Err("another row already has that sku")
t.text("name", { required: true }); // missing or blank on insert, or blanked by an update: Err("name is required")
t.float("price", { index: true }); // an index for filters and ordering
t.int("stock", { default: 0 }); // filled in when an insert leaves it out (or sends "")
t.bool("live", { default: true });
t.json("meta");
})?;
Types are text, int, float, bool, json and ref. Declared int, float and bool fields turn form strings into values ("3" → 3, "on" → true) and refuse anything else, so ctx.db.insert("products", req.form) does the right thing. Declaring a field later adds its index; nothing is ever dropped.
where takes a map of fields. A plain value matches exactly; a map of operators compares:
| Operator | Matches |
|---|---|
eq, ne | equal, not equal (ne includes rows without the field) |
gt, gte, lt, lte | numbers numerically, strings (ISO dates too) in order |
in, not_in | one of a list |
like | a pattern with % and _, ignoring case |
search | contains the text, ignoring case |
contains | a list field holding a value ({ tags: { contains: "oak" } }), or a map field holding those keys and values |
exists | the field is there and not null (false: it is not) |
ctx.db.find("products", { where: {
price: { gte: 10, lt: 50 },
"meta.kind": "wood", // nested fields, four levels at most
created_at: { gte: now() - 7 * 86400 }, // created_at and updated_at take unix seconds or ISO dates
or: [{ stock: { gt: 0 } }, { sku: { in: ["A1", "A2"] } }],
}, order: "price desc, name", limit: 20 })?;
A map whose keys are all operators is a condition; one with no operator keys is a value to match. Mixing them (a misspelt operator) is an error. id, created_at and updated_at can be filtered and ordered on like fields.
Store
ctx.store keeps small values by key for the module: counters, cached answers, one-off tokens. Keys are up to 200 characters; values are anything JSON can hold. { ttl: "10m" } (or seconds) makes a key expire. incr adds in one statement and, with a ttl, counts per window: the key starts again from zero when it expires, which is what a rate limit needs. A module sees only its own keys; no permission is needed. gansey mod remove --purge forgets them.
Settings
ctx.settings.define takes a map of setting names to fields, in the order the form should show them. Each field has a type, a label, an optional help line, a default, and an optional section heading.
| Type | Value | Extra |
|---|---|---|
bool | true / false | shown as a switch |
int, float | a number | min, max |
text, textarea | a string | placeholder |
select | one of the options' values | options: [["webp", "WebP"], ["avif", "AVIF"]] (or plain strings) |
lines | a list of strings | edited one per line; blank lines dropped |
secret | a string | never shown back in the form; saving it blank keeps the stored one |
Values live in the site's settings table (module.<id>), so they survive reloads and switching the module off. The performance module (gansey-module-performance) is the example.
Files
A module with files = true keeps files on disk under [modules].files_dir (default: module-files beside the media folder), in a folder of its own; nothing goes in the database. A multipart form posted to the module's admin screen or to one of its POST routes is streamed there and hashed on the way, and the handler gets each file field as req.files.<field> = { id, name, size, sha256, content_type }. Uploads are capped at 512 MB. Keep the id in a table, or ctx.files.delete a file you do not want; a module never sees another module's files. In an admin screen, <input type="file"> in a form is all it takes.
Media
Uploads get sizes in their own format (JPEG or PNG). Next-generation copies (WebP, AVIF) are made on request: the media library's Optimise and Optimise all buttons, or a module with media = "manage" calling ctx.media.optimise. Core does the encoding on a background queue, one image at a time; the module only says which formats and qualities to use (ctx.media.configure). Each item records what its copies were made with, so after a change the library shows which images are out of date. Pages offer AVIF first, then WebP, then the JPEG or PNG.