Client SDKs
Loomup clients share the same auth, REST, realtime, storage, and offline-sync protocols. Published JavaScript packages live in bluppco/loomup-js; source-available Swift, Kotlin/Android, and Dart/Flutter clients live in bluppco/loomup-native. This server repository contains protocol documentation, not SDK source or tests.
JavaScript and TypeScript
| SDK | Path | Package |
|---|---|---|
| TypeScript | packages/client | @loomup/client |
| Browser offline SQLite | packages/offline | @loomup/offline |
| TanStack Query | packages/tanstack-query | @loomup/tanstack-query |
| React | packages/react | @loomup/react |
| React Native | packages/react-native | @loomup/react-native |
| Next.js | packages/next | @loomup/next |
| Nuxt | packages/nuxt | @loomup/nuxt |
| Astro | packages/astro | @loomup/astro |
| Schema CLI | packages/cli | @loomup/cli |
Native SDKs
The native repository is continuously tested. Swift is released from the repository through Swift Package Manager semantic-version tags; the current release is 0.1.3. It is not listed in Swift Package Index or published through a Swift package-registry server. Kotlin and Dart remain source-only and are not published to Maven Central or pub.dev; use a local clone or pin an exact Git commit for those clients. Do not depend on an untagged branch for a production build.
| Platform | Source | Requirements |
|---|---|---|
| Swift | root Package.swift, products Loomup and LoomupAppIntegrity | Swift 5.9+, iOS 16+/macOS 12+ |
| Kotlin/JVM and Android | kotlin/ and kotlin/:android | JVM 11+, Android minSdk 23 for the Android library |
| Dart and Flutter | flutter/, package loomup | Dart 3+, no Flutter framework dependency |
Swift Package Manager, using the current Git release:
.package(
url: "https://github.com/bluppco/loomup-native.git",
from: "0.1.3"
)
Link .product(name: "Loomup", package: "loomup-native"); add LoomupAppIntegrity only for App Attest integration. Use an exact 0.1.3 requirement when automatic patch updates are not desired. A local clone can use .package(path: "../loomup-native").
Kotlin/JVM from a local clone:
// settings.gradle.kts
includeBuild("../loomup-native/kotlin")
// build.gradle.kts
dependencies {
implementation("com.loomup:client:0.1.0")
}
That coordinate is supplied by the included source build; it is not a Maven Central release.
Dart or Flutter from a local clone:
dependencies:
loomup:
path: ../loomup-native/flutter
All four core clients use the same everyday model:
| Area | Common methods |
|---|---|
| Auth | signUp/register, signIn/login, me, refresh, signOut/logout, setToken, setSession |
| Resources | from(table).select, get, insert, update, delete |
| Realtime | subscribe, subscribeReady, onControl, closeRealtime |
| Offline | offline, then find, get, create, update, remove, status, sync, setOnline, close |
| Storage | storage.from(bucket), then upload, download, list, remove |
Host languages use their normal argument labels and dynamic JSON value types. See the canonical LLM implementation reference for copyable Swift, Kotlin, Dart, and TypeScript examples and the exact behavior of these methods.
Declarative schema CLI
Projects can declare their database shape without SQL in loomup.schema.yaml:
$buckets:
- attachments
$realtime:
tables: [todos]
todos:
title: text
completed: boolean
owner_id: user
created_at: datetime
updated_at: datetime
$indexes:
- [owner_id, completed]
loomup init also creates loomup.access.ts. Developers choose an app-level
profile and identify only product-specific resource categories; Loomup follows schema references and
compiles the server-enforced table and object permissions internally:
import type { LoomupAccessConfig } from "@loomup/client/access";
export default {
profile: "workspace-project",
publishedContent: [{ table: "updates" }],
memberContent: ["issues"],
} satisfies LoomupAccessConfig;
The application does not author SQL, relationship expressions, or separate
rules for every child table. loomup migrate sends the compiled manifest with
the schema plan; Loomup validates complete coverage before applying it.
Durable inbox notifications
Declare an application-owned inbox table plus $notifications to project
canonical journal events into recipient-scoped rows. The projector runs inside
Loomup, suppresses notifications where actor and recipient are the same user,
and uses the configured event key/dedupe fields so replay is idempotent. Clients
read, subscribe to, and acknowledge notification rows through ordinary Resource
APIs; they cannot create or delete them when the access profile declares the
table as notifications.
notifications:
recipient_id: user
actor_id: user
issue_id: issues
type:
enum: [issue_assigned]
event_key: text
issue_title: text
read_at: datetime?
created_at: datetime
updated_at: datetime
$indexes:
- unique: event_key
- [recipient_id, read_at, created_at]
$notifications:
table: notifications
recipient_field: recipient_id
actor_field: actor_id
type_field: type
event_key_field: event_key
push_title: My app
push_body: "{{issue_title}}"
events:
- type: issue_assigned
source: issues
operations: [INSERT, UPDATE]
recipient: assignee_id
changed: assignee_id
fields:
issue_id: id
issue_title: title
Relationship paths such as issue_id.project_id may be used in fields. Set an
event's optional actor to a relationship path ending in a user or users
reference (for example, comment_id.created_by) when the domain actor differs
from the authenticated writer that created the source row. $actor.<field>
snapshots a field from that configured actor, or from the authenticated user
when actor is omitted. recipient, changed, and dedupe name fields on the
source table.
Add the notification table to $realtime.tables for live inbox badges. If push
is configured, projected inserts also enter the same portable push outbox
described below.
Install @loomup/cli, create a project API key with Schema · Apply, and link
the local package to the hosted Loomup project once:
npm install @loomup/client
npm install --save-dev @loomup/cli
npm exec loomup init
export LOOMUP_API_KEY='loomup_sk_…'
loomup link --url https://platform.example.com --project <project-id>
npm exec loomup migrate --plan
npm exec loomup migrate
If $email already matches either the latest stored schema or the active
project email configuration, an unrelated schema change does not repeat SES
preflight. This also covers projects whose email settings predate stored schema
email metadata. Changing effective email settings continues to require a
successful SES preflight.
The CLI generates .loomup/client.ts from the YAML during init, link, and a
successful migrate. Realtime is disabled by default; $realtime.tables is the
complete server allowlist, and generated clients expose a typed RealtimeTable
union so application subscription helpers can reject undeclared tables. Set
package.json#loomup.output to check the client into another path and run
loomup generate --check in CI. The generated module owns all
table/insert/update types and the non-secret project gateway URL:
import { createDb } from "./.loomup/client";
const db = createDb({ serviceKey: env.LOOMUP_API_KEY });
const todos = await db.todos.list({ where: { completed: false } });
const todo = await db.todos.create({ title: "Ship", completed: false });
await db.todos.update(todo.id, { completed: true });
await db.todos.delete(todo.id);
Run npm exec loomup generate to refresh the client without contacting Loomup.
Pass url to createDb to override the generated gateway at runtime. The API
key is never emitted into generated code.
For terminal debugging, a hosted workspace member can use the platform session
saved by loomup auth login instead of creating a Resource key:
loomup data resources
loomup data summary
loomup data list todos --where completed=false --all
loomup data get todos <record-id>
The manager read endpoint accepts the same equality and typed filters,
projection, sorting, limit, offset, and total metadata as the public Resource
list API. CLI list output is tabular by default; --json preserves the
{ data, meta } envelope and --jsonl is available for pipelines. Use
--use-project-key with LOOMUP_API_KEY when the goal is to reproduce the
application's Resource-scope authorization instead of manager visibility.
link writes only url, project, schema, and access under package.json#loomup;
it never stores the key. Use a schema:plan key for read-only checks or a
schema:apply key for deployment; Apply also permits planning. A human
LOOMUP_PLATFORM_TOKEN remains available for interactive use. migrate always
requests a plan first. Field removals and type changes require
--allow-data-loss; data that would
violate a requested required, foreign-key, enum, boolean, JSON, or unique
constraint blocks the migration.
Existing projects keep the managed ID representation recorded by their latest
schema revision. When adopting an unversioned database, Loomup infers only a
sole id primary key and never converts it during an ordinary migration. New
projects use UUID text IDs; newly declared tables in an adopted project continue
that project's unambiguous established ID representation. A mixed-ID project
must be normalized through an explicit reviewed migration before adding tables.
Safe missing fields use ALTER TABLE, and missing
indexes use CREATE INDEX, so unrelated tables are not rebuilt. Dry-run plan
responses report the current stored revision. Apply creates a verified snapshot
and commits the database, config, schema source, and ownership metadata as one
recoverable operation. An interrupted apply is rolled back when the control
plane reopens, and a hosted-runtime reload failure automatically restores the
snapshot and prior files before serving the project again.
For a server or CI credential that must also call Resources and operations, use
Studio's Full backend preset. It stores project:backend, so Resources and
operations added later are automatically available without replacing the key.
Top-level keys are table names and ordinary keys are fields. A trailing ?
makes a field nullable; another table name creates a reference; $indexes
accepts ordinary and unique declarations. Loomup adds a reserved, managed id
primary key to every table. New projects use UUID text IDs, while adopted
projects retain and continue their unambiguous established representation. Do
not declare id or a separate primary key;
use a unique index for natural or composite keys. It also derives SQLite, millisecond
timestamp and boolean defaults, authenticated CRUD exposure, and internal
revision history.
TypeScript SDK
Package: @loomup/client (SDK repository path: packages/client).
For React apps, see also the React SDK (@loomup/react).
For a batteries-included browser SQLite client, see Offline sync (@loomup/offline).
For React Native / Expo, see React Native SDK (@loomup/react-native).
For Next.js (App Router + Pages Router cookie sessions), see Next.js SDK (@loomup/next).
For Astro SSR, cookie auth, and islands, see Astro SDK (@loomup/astro).
For Nuxt 3/4 (module, cookie sessions, composables), see Nuxt SDK (@loomup/nuxt).
Install
npm install @loomup/client
Quick start
import { createClient } from "@loomup/client";
const client = createClient({ url: "http://127.0.0.1:3000" });
await client.auth.signUp({ email: "a@b.com", password: "secret12" });
const { data } = await client.from("todos").select({
where: { completed: false }, // coerced to SQLite 0/1 on the server
limit: 20,
});
// Prefer subscribeReady when the next line mutates data.
const unsub = await client.from("todos").subscribeReady((ev) => {
console.log(ev.op, ev.data);
});
// ...
unsub();
client.closeRealtime();
Tokens
setToken(access)re-authenticates an open WebSocket and re-sends all active subscriptions (same as internal refresh rotation).setSession({ access_token, refresh_token })sets both and invokesonTokenswhen configured.onTokens?: (tokens: AuthTokens | null) => voidoncreateClientfires after sign-in, refresh,setSession, and withnullon sign-out — used by@loomup/nextto write cookies.- Automatic 401 retry uses
refreshTokenwhen set. - RESYNC catch-up events use Unix seconds for
ts(same unit as server CDC events).
Object storage
When the server has [storage].enabled = true and buckets declared in loomup.toml, use client.storage:
// List configured buckets
const buckets = await client.storage.listBuckets();
// Upload (raw body — not JSON)
const meta = await client.storage.from("avatars").upload(
"user-1/profile.png",
fileBytesOrBlob,
{ contentType: "image/png", upsert: true },
);
// Download
const blob = await client.storage.from("avatars").download("user-1/profile.png");
// List / delete
const { data, meta: listMeta } = await client.storage
.from("avatars")
.list({ prefix: "user-1/", limit: 50 });
await client.storage.from("avatars").remove("user-1/profile.png");
// or multiple: .remove(["a.png", "b.png"])
See storage.md for server config and HTTP details. Framework adapters (@loomup/react, @loomup/next, …) expose the same API via the underlying @loomup/client.
Push (device registration)
When push is enabled by a portable $push or Studio Resource declaration, register OS/Expo tokens so CDC-driven notifications can be delivered. Obtain the token from Expo Notifications, FCM, APNs, or the browser Push API — the SDK stores the device registration while Web Studio/CLI manages provider credentials.
await client.push.registerDevice({
token: expoPushToken,
provider: "expo", // or "fcm" | "apns" | "webpush"
platform: "ios",
});
const devices = await client.push.listDevices();
await client.push.unregisterDevice({ token: expoPushToken });
// or by server id:
// await client.push.unregisterDevice(device.id);
Full guide: Push notifications.
Types
loomup gen typescript --config loomup.toml --output ./loomup-types.ts
Generated insert/update types accept explicit null for nullable columns. BLOB fields are base64 strings; BOOLEAN columns are TypeScript boolean.
Generate typed named-operation wrappers from manifest v1 with loomup gen sdk --language <typescript|swift|kotlin|dart>. The wrappers cover queries, transactional commands and batches, named FTS searches, and durable job enqueueing; see Typed operations.
Publish workflow (maintainers)
All JavaScript packages version and release together from bluppco/loomup-js.
Push a matching v<version> tag there; its GitHub workflow tests, packs, and
publishes through npm trusted publishing/OIDC. Do not publish from this server
repository or with a local npm token.
Node.js without a global WebSocket
Browsers and modern runtimes expose globalThis.WebSocket. Classic Node does not. Install a compatible implementation and inject it:
npm install ws
# optional types: npm install -D @types/ws
import WebSocket from "ws";
import { createClient } from "@loomup/client";
const client = createClient({
url: "http://127.0.0.1:3000",
WebSocketImpl: WebSocket as unknown as typeof globalThis.WebSocket,
});
// REST-only usage does not require WebSocketImpl.
await client.auth.signIn({ email: "user@example.com", password: "secret12" });
// Realtime needs the injected constructor:
client.from("todos").subscribe((ev) => console.log(ev));
The SDK throws no_websocket if you call realtime without a global or injected WebSocketImpl.
Reconnect
On unexpected close the SDK reconnects with exponential backoff + full jitter (base 1s, cap 30s), re-subscribes, then refetches current authorized state as op: "RESYNC" events. It does not replay deletes or intermediate updates. Set primary keys for custom PK tables:
client.setTablePrimaryKey("keys", "slug");
TanStack Query SDK
Package: @loomup/tanstack-query (SDK repository path: packages/tanstack-query).
Framework-agnostic query/mutation options, stable query keys, and realtime → cache helpers on top of @loomup/client. Use with @tanstack/react-query, Vue, or Solid adapters — this package peers on @tanstack/query-core only (no React dependency).
Install
# monorepo path
npm install ../path/to/packages/client ../path/to/packages/tanstack-query
# peers
npm install @tanstack/query-core
# React apps also need:
# npm install @tanstack/react-query
# or after publish:
# npm install @loomup/client @loomup/tanstack-query @tanstack/react-query
Build from the monorepo:
cd packages/client && npm install && npm run build
cd ../tanstack-query && npm install && npm test
Quick start (React)
import { createClient } from "@loomup/client";
import { createLoomupQuery, loomupKeys, invalidateTable } from "@loomup/tanstack-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
const client = createClient({ url: "http://127.0.0.1:3000" });
const lb = createLoomupQuery(client);
function Todos() {
const qc = useQueryClient();
const list = useQuery(
lb.from("todos").selectOptions({ where: { completed: false }, limit: 20 }),
);
const insert = useMutation(lb.from("todos").insertOptions({ queryClient: qc }));
// Realtime: patch detail rows + invalidate list queries
useEffect(() => lb.from("todos").syncRealtime(qc), [qc]);
// Pure mutationFn + manual invalidation also works:
// useMutation({
// ...lb.from("todos").insertOptions(),
// onSuccess: () => invalidateTable(qc, "todos"),
// });
return (
<ul>
{list.data?.data.map((row) => (
<li key={String((row as { id: string }).id)}>
{(row as { title?: string }).title}
</li>
))}
</ul>
);
}
Query keys
| Operation | Key |
|---|---|
| All Loomup | ["loomup"] |
| Table | ["loomup", table] |
| List | ["loomup", table, "list", stableFilters?] |
| Detail | ["loomup", table, "detail", id] |
| Auth me | ["loomup", "auth", "me"] |
List filters are JSON-serialized with sorted object keys, so { where: { b: 1, a: 2 } } and { where: { a: 2, b: 1 } } share a cache entry. Export loomupKeys for manual invalidateQueries / setQueryData.
Options API
| Helper | Role |
|---|---|
selectOptions(filters?, overrides?) | List query |
getOptions(id, overrides?) | Detail query |
insertOptions({ queryClient? }) | Insert mutation; optional cache invalidate + set detail by id |
updateOptions({ queryClient? }) | Update mutation; vars { id, patch } |
deleteOptions({ queryClient? }) | Delete mutation |
auth.meOptions / signInOptions / signUpOptions / signOutOptions | Auth; sign-out removes all ["loomup"] queries when queryClient is set |
syncRealtime(queryClient, { rowId? }) | Subscribe + cache sync; returns unsubscribe |
Realtime cache policy
- INSERT / UPDATE / RESYNC with
data:setQueryDataon the detail key, then invalidate list keys for that table. - DELETE: remove the detail query, then invalidate lists.
Realtime is opt-in (syncRealtime); REST-only apps never open a WebSocket from this package.
Publish workflow (maintainers)
Release this package through the lockstep bluppco/loomup-js GitHub workflow.