Loomup Docs
Guide /docs/sdk

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

SDKPathPackage
TypeScriptpackages/client@loomup/client
Browser offline SQLitepackages/offline@loomup/offline
TanStack Querypackages/tanstack-query@loomup/tanstack-query
Reactpackages/react@loomup/react
React Nativepackages/react-native@loomup/react-native
Next.jspackages/next@loomup/next
Nuxtpackages/nuxt@loomup/nuxt
Astropackages/astro@loomup/astro
Schema CLIpackages/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.

PlatformSourceRequirements
Swiftroot Package.swift, products Loomup and LoomupAppIntegritySwift 5.9+, iOS 16+/macOS 12+
Kotlin/JVM and Androidkotlin/ and kotlin/:androidJVM 11+, Android minSdk 23 for the Android library
Dart and Flutterflutter/, package loomupDart 3+, no Flutter framework dependency

Swift Package Manager, using the current Git release:

swift
.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:

kotlin
// 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:

yaml
dependencies:
  loomup:
    path: ../loomup-native/flutter

All four core clients use the same everyday model:

AreaCommon methods
AuthsignUp/register, signIn/login, me, refresh, signOut/logout, setToken, setSession
Resourcesfrom(table).select, get, insert, update, delete
Realtimesubscribe, subscribeReady, onControl, closeRealtime
Offlineoffline, then find, get, create, update, remove, status, sync, setOnline, close
Storagestorage.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:

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:

ts
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.

yaml
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:

bash
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:

ts
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:

bash
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

bash
npm install @loomup/client

Quick start

ts
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 invokes onTokens when configured.
  • onTokens?: (tokens: AuthTokens | null) => void on createClient fires after sign-in, refresh, setSession, and with null on sign-out — used by @loomup/next to write cookies.
  • Automatic 401 retry uses refreshToken when 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:

ts
// 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.

ts
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

bash
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:

bash
npm install ws
# optional types: npm install -D @types/ws
ts
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:

ts
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

bash
# 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:

bash
cd packages/client && npm install && npm run build
cd ../tanstack-query && npm install && npm test

Quick start (React)

ts
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

OperationKey
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

HelperRole
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 / signOutOptionsAuth; 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: setQueryData on 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.