Offline sync v1
Loomup sync v1 turns exposed resources into an authorization-scoped local cache with ordered replay and an idempotent offline mutation queue. It is an opt-in preview beside the compatible REST and WebSocket APIs.
Browser SQLite quickstart
@loomup/offline owns SQLite/WASM setup, persistence, connectivity events, realtime invalidation, and SyncStore lifecycle:
import { createOfflineClient } from "@loomup/offline";
const offline = await createOfflineClient({
url: "https://api.example.com",
database: "north-gate.sqlite",
resources: ["attendees", "checkins"],
});
const checkins = offline.from("checkins");
await checkins.create({
id: crypto.randomUUID(),
attendee_id: "attendee-1",
entrance_id: "north",
});
offline.subscribe((status) => console.log(status.phase, status.pending));
Each named database is a real SQLite/WASM file whose binary snapshot is persisted in an IndexedDB file container. Applications do not create Loomup storage tables or configure sql.js.
Low-level TypeScript
import {
indexedDbSyncStorage,
createProject,
SyncStore,
} from "@loomup/client";
const project = createProject({
url: "https://api.example.com",
token: session.access_token,
refreshToken: session.refresh_token,
});
const sync = await SyncStore.open(project, {
resources: ["items"],
storage: indexedDbSyncStorage(),
primaryKeys: { items: "id" },
});
const items = sync.find("items");
await sync.setOnline(false);
await sync.create("items", { title: "Works offline" });
await sync.update("items", items[0].id as string, { completed: true });
await sync.setOnline(true); // uploads, pulls, and converges
SyncStore remains available when an application needs a custom persistence adapter. It attempts realtime invalidation automatically and supports an optional polling interval. Its durable storage contract is three asynchronous methods: getItem, setItem, and removeItem. The default in-memory adapter is useful for servers/tests; indexedDbSyncStorage() is a lightweight non-SQLite browser alternative. browserSyncStorage(window.localStorage) remains a small explicit option.
Persisted state is isolated by project URL (the client url), auth subject, and resource list. The default storage key includes the project URL (loomup.sync.v1:<url>); even with a custom shared storageKey, a payload stamped for project A is not loaded by project B.
React
import { SyncStoreProvider, useSyncResource } from "@loomup/react";
function Items() {
const { data, status, create, update, conflicts } = useSyncResource("items");
return <p>{data.length} items · {status.phase} · {conflicts.length} conflicts</p>;
}
root.render(
<SyncStoreProvider store={sync}>
<Items />
</SyncStoreProvider>,
);
Local creates, updates, and deletes are optimistic. Each carries a stable mutation ID. Exact retries return the same server receipt; they cannot repeat the application write.
React Native SQLite
import * as SQLite from "expo-sqlite";
import { sqliteSyncStorage } from "@loomup/react-native";
const database = await SQLite.openDatabaseAsync("loomup-local.db");
const storage = await sqliteSyncStorage(database);
const sync = await SyncStore.open(client, {
resources: ["items"],
storage,
});
The adapter is structural and does not bundle Expo SQLite. The same interface can wrap another platform SQLite binding.
Swift, Kotlin, and Dart
Offline sync is a protocol capability, not a JavaScript-only database. The native SDKs use the same bootstrap, ordered pull, stable mutation, cursor-reset, and realtime-invalidation flow. Application code works with records and status; it does not create Loomup tables or run migrations.
Swift
let storage = try SQLiteSyncStorage(
url: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("loomup-local.sqlite")
)
let offline = try await client.offline(resources: ["items"], storage: storage)
try await offline.create("items", data: ["title": "Works offline"])
for await status in await offline.statusStream() {
print(status.phase, status.pending)
}
SQLiteSyncStorage uses the SQLite library shipped by Apple. MemorySyncStorage is available for tests.
Kotlin
val storage = SQLiteSyncStorage(File("loomup-local.sqlite"))
val offline = client.offline(resources = listOf("items"), storage = storage)
offline.create("items", mapOf("title" to JsonValue.String("Works offline")))
offline.status.collect { println("${it.phase} ${it.pending}") }
SQLiteSyncStorage is the JVM adapter and requires a SQLite JDBC driver at runtime (for example, runtimeOnly("org.xerial:sqlite-jdbc:3.47.1.0")). Android apps can implement the three-method SyncStorage interface with Room or platform SQLite without changing OfflineStore.
Dart / Flutter
final storage = await SQLiteSyncStorage.open(yourDatabaseAdapter);
final offline = await client.offline(resources: ['items'], storage: storage);
await offline.create('items', {'title': 'Works offline'});
offline.statuses.listen((status) => print('${status.phase} ${status.pending}'));
SQLiteSyncDatabase is deliberately structural, so the app can adapt sqflite, Drift, or another native SQLite package instead of Loomup forcing a plugin choice. Use MemorySyncStorage in pure Dart tests.
client.offline(...) performs the initial bootstrap and automatically subscribes to realtime-enabled synchronized resources. A write from another browser or native app triggers an ordered pull. For a resource captured only by history or push, configure pollIntervalMs or call sync() explicitly because that resource has no realtime channel. Local writes update immediately, remain queued without connectivity, and replay when setOnline(true) or sync() runs. Call close() when the store's lifecycle ends.
Protocol
| Endpoint | Purpose |
|---|---|
GET /sync/v1/bootstrap | Authorized rows, record versions, schema fingerprint, and safe cursor |
GET /sync/v1/pull | Ordered events after a cursor |
POST /sync/v1/mutations | One to 100 stable offline mutations |
Bootstrap and pull require a stable client_id. A client must bootstrap before its first pull; an unknown id receives reset_required with reason bootstrap_required instead of bypassing the stored schema and authorization fingerprint. The server records the accepted cursor so event compaction cannot pass an active client silently. A bootstrap cursor is captured before rows are read: concurrent writes can be delivered twice, but cannot be missed.
The supported cursor window defaults to 30 days of client inactivity (events.sync_cursor_ttl_secs). This bounds permanent retention without risking stale data: an older client is deliberately removed from the compaction barrier and receives reset_required when it returns.
Pull re-evaluates the current read rule against both sides of every event using the same database-backed evaluator as REST and bootstrap. When a direct row change makes that row invisible, the caller receives a scoped DELETE.
Rules using exists(...) or lookup(...) can change the visibility of many cached rows when a membership or parent row changes. Loomup journals those dependency tables and sends a payload-free INVALIDATE change on each dependent resource's realtime channel. That wakes live offline stores without exposing the dependency row. For clients subject to read rules, pull checks the dependency journal and selects its event page in one SQLite read transaction, then returns reset_required for permission-relevant or uncertain changes before accepting or advancing the client's cursor. The client re-bootstraps, which removes newly private rows and adds newly visible rows without leaving a permanently skipped event. Identity changes likewise purge in-memory rows before the new identity can read them.
An offline read rule may not use now(): time can change visibility without producing an event, so Loomup rejects that configuration instead of creating a cache it cannot invalidate safely.
Conflicts
Updates and deletes include the cached record's base_sequence. If the current server sequence differs, the result is an explicit version_conflict with expected/current versions. SyncStore keeps the optimistic state and exposes the conflict. Applications can:
resolveConflict(id, "discard")to bootstrap canonical server state.resolveConflict(id, "retry", mergedData)to retry against the current sequence.
There is no universal silent last-write-wins policy and no generic CRDT claim in v1.
Reset and schema changes
If compaction has passed a cursor, a permission-relevant or uncertain relationship dependency changed for a client subject to read rules, the schema/authorization context differs, or the client_id was never bootstrapped, pull returns reset_required without advancing the accepted client cursor. SyncStore safely re-bootstraps and reapplies still-pending optimistic mutations. The schema fingerprint covers table structure, primary-key configuration, current read rules, and the authenticated identity, role, admin state, and bypass mode. Corrupt persisted JSON is discarded rather than partially trusted; malformed server journal payloads fail the pull rather than being omitted while the cursor advances.
Service keys with project:backend or the requested resource:<name>:read scopes
bypass row read rules. Admins also bypass them when admin.rule_bypass = true.
These clients continue from their saved cursor across authorization dependency
updates, inserts, and deletes without a reset or new bootstrap. Resource scopes
are still enforced. Retention, schema/authorization fingerprint changes, and
unregistered client IDs still require a reset, including changes to bypass mode
or read-rule definitions. Dependency capture and realtime invalidation remain active.
Field-aware authorization resets
For clients subject to read rules, sync tracks the table columns read by exists(...)
and lookup(...), including nested lookup filters and result columns. An update
that leaves all these inputs unchanged can be pulled incrementally. For example,
editing an issue title does not reset comments whose access depends only on the
issue's project ID. Moving the issue or changing membership still resets sync.
Insert/delete events, primary-key changes, missing or invalid event payloads, and uncertain dependency analysis remain conservative. Lookup optimization requires primary-key equality filters that identify one row; ambiguous lookups still reset on every update. Dependency events are examined in bounded batches on the same SQLite snapshot as journal bounds, page selection, and authorization. Changes relevant to any requested resource reset the whole requested snapshot. Realtime invalidation remains table-based; clients may still pull after harmless changes.
Retention, schema/authorization-context changes, and unregistered clients retain
their existing 409 reset_required behavior and wire format. See
Observability for reset-reason and bootstrap correlation logs.