Resources, templates, and schema planning
Loomup's preferred application model is a resource. Resources describe domain concepts such as projects, tasks, rooms, messages, or items; Loomup lowers them into normal SQLite tables, secure access rules, generated types, REST compatibility, and realtime subscriptions.
Create from a template
loomup new my-app --template saas
cd my-app
loomup dev
Built-in templates:
| Template | Initial resources | Intended use |
|---|---|---|
blank | None | Start from an empty application manifest |
saas | Projects and tasks | User-owned SaaS data |
collaboration | Rooms and messages | Authenticated shared realtime data |
mobile | User-owned items | Mobile/offline-oriented starter |
Templates are starting points, not proprietary database formats. The resulting tables remain normal SQLite.
Application manifest
loomup.app.toml contains application intent:
version = 1
name = "Acme"
[resources.tasks]
access = "owner"
owner_field = "user_id"
[resources.tasks.fields.user_id]
type = "user"
required = true
index = true
[resources.tasks.fields.title]
type = "text"
required = true
[resources.tasks.fields.completed]
type = "boolean"
required = true
default = false
Generated fields are id, created_at, and updated_at. Supported application field types are text, integer, real, boolean, json, datetime, and user.
Resources use autoincrementing integer IDs by default. Offline-first applications can opt into client-generated text/UUID IDs without writing SQL:
[resources.checkins]
id_type = "text"
access = "authenticated"
Access presets:
private: denied unless explicit rules are supplied.public: readable and writable without authentication.authenticated: all operations require a signed-in user.owner: reads, updates, deletes, and subscriptions requireowner_field = auth.uid(); creates require authentication.
Explicit rule expressions remain available as an advanced escape hatch with [resources.<name>.rules].
Schema planning
loomup plan
The planner compares the manifest to the live SQLite schema.
Safe actions include:
- Creating a resource.
- Adding a nullable field.
- Adding a required field with a portable default.
- Adding an index.
Blocked actions include:
- Adding a required field without a default/backfill.
- Changing SQLite affinity.
- Tightening a nullable field to required.
Loomup never removes an unmanaged field automatically. It reports the difference as a warning.
Apply safe actions explicitly:
loomup apply
Load repeatable development fixtures from a resource-shaped JSON file:
loomup seed seeds/development.json
{
"tasks": [
{ "id": "task-001", "title": "Works offline", "completed": false }
]
}
Seed inserts are transactional and idempotent: rows that already satisfy a primary-key or unique constraint are skipped.
loomup dev performs the same safe apply before starting, so local development and deployment share one planner.
Every applied manifest is hashed into _lb_schema_versions with its journal boundary and readable plan. Managed deploy-project and environment promotion call this exact planner and create a verified rollback snapshot before applying.
Resource client
Standalone YAML projects get a ready-to-use client from loomup init, link,
migrate, or generate:
import { createDb } from "./.loomup/client";
const db = createDb(env);
const tasks = await db.tasks.list({ sort: "-created_at" });
const task = await db.tasks.create({
user_id: session.user.id,
title: "Ship",
completed: false,
});
await db.tasks.update(task.id, { completed: true });
await db.tasks.delete(task.id);
The generated module supplies its row, insert, and update types to
@loomup/client; application code does not declare generics. find() remains
available when list pagination metadata is needed. Dynamic resource names use
db.resource("tasks"), and existing client.from("tasks") calls remain
supported.
For joins, aggregates, transactional domain workflows, FTS, and background work, declare named operations instead of expanding resource CRUD into arbitrary SQL. See Typed operations, joins, search, and jobs.
Specialized built-ins keep the same core verbs where their backing store supports them:
const currentUsers = await db.users.find();
const me = await db.users.get("me");
const avatars = db.files.from("avatars");
const files = await avatars.find({ prefix: `${me.id}/` });
await avatars.create({ path: `${me.id}/photo.png`, body: image, contentType: "image/png" });
await avatars.update(`${me.id}/photo.png`, { body: replacement, contentType: "image/png" });
await avatars.remove(`${me.id}/photo.png`);
permissions() reports unavailable verbs for specialized resources rather than inventing history or subscriptions their physical store does not provide. The legacy auth and storage namespaces remain compatibility facades.
Every generated resource also exposes capabilities and durable history:
const permissions = await db.tasks.permissions(task.id);
const history = await db.tasks.history(task.id, { limit: 50 });
const previous = await db.tasks.at(task.id, {
sequence: history.data[0].sequence - 1,
});
History is durable journal data, not a transient realtime buffer. Loomup re-evaluates the current read rule independently for every before/after state and redacts inaccessible states. Point-in-time reads accept an inclusive event sequence or Unix timestamp and enforce the same rule.
Live collections
const tasks = await project.tasks.live();
const stop = tasks.onChange(({ data, meta }) => {
render(data);
});
await tasks.refresh();
stop();
tasks.close();
live() acknowledges the subscription before reading the initial snapshot and buffers changes during that read. Its default refetch strategy remains correct for filtered/sorted lists. Use { strategy: "merge" } for an unfiltered list when local insert/update/delete merging is desired.
Existing projects
Preview adoption without changing anything:
loomup upgrade --dry-run
Apply the upgrade to write a manifest derived from the current schema and rules:
loomup upgrade
No application table or row is changed by manifest adoption. See documents/COMPATIBILITY.md for the support policy.
Query retained history without record IDs
POST /api/<table>/_loomup/history finds historical rows even after deletion.
Use one to eight any_of predicates with an introspected field and exact JSON
scalar equals value. Null, boolean, number, and strings up to 4096 UTF-8 bytes are
supported; unknown fields and nonscalar values return 400.
const page = await db.issue_label_assignments.queryHistory({
anyOf: [{ field: "issue_id", equals: issueId }],
limit: 50,
});
// Continue even if page.data is empty; the bounded scan may have no matches.
const next = page.meta.next_before_sequence == null ? null
: await db.issue_label_assignments.queryHistory({
anyOf: [{ field: "issue_id", equals: issueId }],
throughSequence: page.meta.through_sequence,
beforeSequence: page.meta.next_before_sequence,
});
The HTTP body uses any_of, before_sequence, and through_sequence. Limits
default to 50 and range from 1 to 500. Each request examines at most 2000 global
journal events using the existing sequence index, including unrelated resources.
Events are newest first; continue with identical predicates and the returned
inclusive through_sequence to hold the upper boundary fixed. Only null
next_before_sequence signals completion. This read works on existing databases
without a new schema, index, backfill, or notification replay.
The existing resource Read credential scope and database routing apply. Current read policy independently redacts each historical state before matching filters; a hidden side cannot cause inclusion. A one-sided UPDATE remains UPDATE and must not be interpreted as a creation or removal. Retention still determines which events are available. Queries never advance consumers or resend messages.