Type-safe Zustand stores auto-generated from your Supabase schema. Offline-first, realtime, with optimistic updates.
- Auto-generated, type-safe stores from Supabase
Databasetypes — filters, mutations, and hooks fully typed - Optimistic mutations with automatic rollback, validation, and conflict resolution
- Offline-first with persistent queue, coalescing, dependency tracking, and auto-flush on reconnect
- Realtime & sync — Supabase subscriptions, cross-tab (BroadcastChannel), multi-device, incremental and selective sync
- Caching — query cache strategy (replace/merge), cursor pagination, infinite scroll, cache TTL with stale-while-revalidate
- Platform adapters — Web (localStorage/IndexedDB) and React Native (expo-sqlite/AsyncStorage/background sync)
- Auth, RSC & Suspense — session-gated stores, RLS awareness, server prefetch, React Suspense
- Resilience — retry with backoff, circuit breaker, rate limiter, encryption at rest, storage quota, schema versioning
- Observability — sync status hooks, sync health metrics, conflict audit trail
- Full Supabase coverage — Storage, Edge Functions, RPC, aggregation (client & server)
npm install @drakkar.software/anchor zustand
# Web adapters
npm install @drakkar.software/anchor-adapter-web
# React Native adapters
npm install @drakkar.software/anchor-adapter-react-nativenpx supabase gen types typescript --project-id $PROJECT_REF > database.types.tsimport { createAnchorClient, createSupabaseStores } from '@drakkar.software/anchor'
import { LocalStorageAdapter, WebNetworkStatus } from '@drakkar.software/anchor-adapter-web'
import type { Database } from './database.types'
const supabase = createAnchorClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_PUBLISHABLE_KEY!, // sb_publishable_... (new format) or the legacy anon key
)
export const stores = createSupabaseStores<Database>({
supabase,
tables: ['todos', 'profiles'],
persistence: { adapter: new LocalStorageAdapter() },
network: new WebNetworkStatus(),
realtime: { enabled: true },
devtools: process.env.NODE_ENV === 'development',
})import { useQuery, useMutation, eq, isPending } from '@drakkar.software/anchor'
function TodoList() {
const { data, isLoading } = useQuery(stores.todos, {
filters: [eq('completed', false)],
})
const { insert, remove } = useMutation(stores.todos)
if (isLoading) return <div>Loading...</div>
return (
<ul>
{data.map(todo => (
<li key={todo.id}>
{todo.title}
{isPending(todo) && <span> (saving...)</span>}
<button onClick={() => remove(todo.id)}>Delete</button>
</li>
))}
<button onClick={() => insert({ title: 'New todo' })}>
Add Todo
</button>
</ul>
)
}Creates a Zustand store for a single Supabase table.
const todosStore = createTableStore<Database, TodoRow, TodoInsert, TodoUpdate>({
supabase,
table: 'todos',
primaryKey: 'id', // default: 'id'
schema: 'public', // default: 'public'
defaultSort: [{ column: 'created_at', ascending: false }],
persistence: { adapter: new LocalStorageAdapter() },
devtools: true,
crossTab: { enabled: true },
validate: {
insert: (data) => data.title?.length > 0 ? true : ['Title required'],
update: (data) => true,
},
extend: (set, get, store, supabase) => ({
completedCount: () =>
[...get().records.values()].filter(t => t.completed).length,
toggleComplete: async (id: number) => {
const current = get().records.get(id)
if (current) await get().update(id, { completed: !current.completed })
},
}),
})Note:
realtime,conflict,network, andofflineQueueoptions requirecreateSupabaseStores()which wires up the shared RealtimeManager and OfflineQueue. UsecreateSupabaseStores()for full-featured stores, or manually set up these features withRealtimeManager,bindRealtimeToStore, andOfflineQueue.
Creates typed stores for multiple tables at once.
const stores = createSupabaseStores<Database>({
supabase,
tables: ['todos', 'profiles', 'comments'],
persistence: { adapter: new LocalStorageAdapter() },
realtime: { enabled: true },
tableOptions: {
todos: { defaultSort: [{ column: 'created_at', ascending: false }] },
profiles: { realtime: { enabled: false } },
},
})
// Fully typed:
stores.todos.getState().insert({ title: 'Buy milk' })
stores.profiles.getState().fetch()
stores._destroy() // Clean up all subscriptionsName them in createSupabaseStores. A view store is read-only (every mutator
throws) and otherwise a normal store: same persistence, same auth gate, same
per-query scoping, readable with useQuery.
const stores = createSupabaseStores<Database>({
supabase,
tables: ['todos'],
views: ['dashboard_stats'],
viewOptions: {
// Worth setting on almost every view: a generated `Database` marks every
// view column nullable, so the default "id" is not safe to lean on, and a
// row that arrives without its key fails the fetch rather than collapsing
// onto another row's record.
dashboard_stats: { primaryKey: 'owner_id' },
},
})
const stats = await stores.dashboard_stats.getState().fetch()
// stores.dashboard_stats.getState().insert({}) // Throws: "Cannot mutate view"A view gets no realtime subscription: Postgres publishes changes under the underlying table's name, so a channel on the view's name would register and never fire. Refetch it from the table store's events, or on foreground, if its freshness matters.
Superseded by views: above. A standalone view store cannot reach the factory's
shared persistence, auth gate, offline queue or cleanup, and its ViewStore<Row>
type hides the three actions useQuery calls (resolveFetchOptions,
retainQuery, releaseQuery), so it cannot be read with the hook the rest of
the library is built around.
Every table store provides these actions:
| Action | Description |
|---|---|
fetch(options?) |
Fetch rows from Supabase with filters/sort/pagination |
fetchOne(id) |
Fetch a single row by primary key |
refetch() |
Re-run the last fetch |
insert(row) |
Insert a row (optimistic) |
insertMany(rows) |
Batch insert (single HTTP request) |
update(id, changes) |
Update a row (optimistic with rollback) |
upsert(row) |
Insert or update (optimistic with rollback) |
remove(id) |
Delete a row (optimistic with rollback) |
subscribe(filter?) |
Subscribe to realtime changes |
hydrate() |
Load from local persistence |
flushQueue() |
Flush the offline mutation queue |
getQueueSize() |
Number of pending mutations |
clearAll() |
Clear all records |
clearAndFetch(options?) |
Clear cache and re-fetch (invalidation for merge mode) |
mergeRecords(rows) |
Merge remote rows (skip pending) |
Declarative data fetching with auto-refetch.
const { data, isLoading, error, refetch, isHydrated } = useQuery(
stores.todos,
{
filters: [eq('completed', false)],
sort: [{ column: 'created_at', ascending: false }],
limit: 20,
enabled: true, // Toggle fetching
deps: [statusFilter], // Refetch when deps change
refetchInterval: 30000, // Auto-refetch every 30s
},
)Type-safe mutations with loading/error tracking.
const { insert, update, upsert, remove, isLoading, error } = useMutation(stores.todos)
await insert({ title: 'New todo', completed: false })
await update(1, { completed: true })
await remove(1)Custom query that auto-refetches when linked stores mutate. Use for queries with joins or complex selects that can't use useQuery directly.
import { useLinkedQuery } from '@drakkar.software/anchor/hooks'
const { data, isLoading, error, refetch } = useLinkedQuery(
() => fetchOfferApplications(supabase, offerId),
{
stores: [stores.applications], // refetch when these stores mutate
deps: [offerId], // refetch when deps change
enabled: !!offerId,
},
)initialData — seed data on mount to avoid loading flash (stale-while-revalidate). The network fetch still fires in the background.
const { data: offer } = useLinkedQuery(
() => fetchOffer(supabase, id),
{
stores: [stores.offers],
deps: [id],
// Read from the store immediately — renders without a loading state
initialData: () => stores.offers.getState().records.get(id),
},
)mergeToStore — write list results back into a store so detail queries can find them via initialData.
// List query populates the store as a side-effect
const { data: offers } = useLinkedQuery(
() => fetchOffers(supabase),
{
stores: [stores.offers],
mergeToStore: stores.offers, // each fetched offer lands in the store
},
)
// Detail query reads from the store instantly — no loading state on navigation
const { data: offer } = useLinkedQuery(
() => fetchOffer(supabase, id),
{
stores: [stores.offers],
deps: [id],
initialData: () => stores.offers.getState().records.get(id),
},
)React Suspense-compatible query. Throws promise while loading.
function TodoList() {
const data = useSuspenseQuery(stores.todos)
return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}
// Wrap in Suspense boundary
<Suspense fallback={<Spinner />}>
<TodoList />
</Suspense>Cursor-based infinite scroll with load-more support.
import { useInfiniteQuery } from '@drakkar.software/anchor/hooks'
function InfiniteTodoList() {
const { data, hasNextPage, fetchNextPage, isLoading } = useInfiniteQuery(
stores.todos,
{
cursorColumn: 'created_at',
pageSize: 20,
sort: [{ column: 'created_at', ascending: false }],
},
)
return (
<>
<ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
{hasNextPage && <button onClick={fetchNextPage}>Load more</button>}
</>
)
}Auth state with automatic session listener.
const { session, user, isLoading, signIn, signOut } = useAuth(stores.auth)
await signIn({ email: 'user@example.com', password: 'secret' })Manages realtime subscription lifecycle.
const { status } = useRealtime(stores.todos)
// status: 'disconnected' | 'connecting' | 'connected' | 'error'Call Postgres functions.
const { data, isLoading, error, refetch } = useRpc<Stats>(
supabase, 'get_dashboard_stats', { user_id: '123' },
)Invoke Supabase Edge Functions.
const { data, isLoading, invoke } = useEdgeFunction<Response>(supabase, 'send-email')
await invoke({ body: { to: 'user@example.com', subject: 'Hello' } })Supabase Storage operations.
const { upload, download, getPublicUrl, list, remove, isLoading } = useStorage(supabase, 'avatars')
await upload('user-123.png', file, { upsert: true })
const url = getPublicUrl('user-123.png')Type-safe filter DSL matching Supabase's PostgREST operators:
import {
eq, neq, gt, gte, lt, lte,
like, ilike, is, inValues,
contains, containedBy, overlaps, textSearch,
match, asc, desc,
} from '@drakkar.software/anchor'
// Comparison
eq('status', 'active')
neq('status', 'archived')
gt('priority', 3)
gte('priority', 3)
lt('priority', 10)
lte('priority', 10)
// Pattern matching
like('title', '%milk%') // case-sensitive
ilike('title', '%milk%') // case-insensitive
// Null/boolean check
is('deleted_at', null)
// Array/set operations
inValues('category', ['work', 'personal'])
contains('tags', ['urgent'])
containedBy('tags', ['urgent', 'important'])
overlaps('tags', ['urgent'])
// Full-text search
textSearch('body', 'hello & world', { type: 'websearch' })
// Match shorthand (multiple eq)
match({ status: 'active', priority: 1 })
// Advanced: not and filter with custom operator
// These accept { op, value } objects for the inner operator
{ column: 'status', op: 'not', value: { op: 'eq', value: 'archived' } }
{ column: 'priority', op: 'filter', value: { op: 'gt', value: 3 } }Alternative to filter arrays:
import { query } from '@drakkar.software/anchor'
const result = await store.getState().fetch(
query<Todo>()
.where('status').eq('active')
.where('priority').gte(3)
.orderBy('created_at', 'desc')
.limit(20)
.build()
)Efficient keyset pagination for large datasets:
import { buildCursorQuery, processCursorResults } from '@drakkar.software/anchor'
const { filters, sort, limit } = buildCursorQuery<Todo>({
cursorColumn: 'created_at',
pageSize: 20,
cursor: lastItem?.created_at,
direction: 'forward',
})
const rows = await store.getState().fetch({ filters, sort, limit })
const { data, pagination } = processCursorResults(rows, { cursorColumn: 'created_at', pageSize: 20 })
// pagination.hasNextPage, pagination.cursorMutations can be queued when offline and flushed on reconnect. This is opt-in, via
offlineQueue.queueWrites, and needs a network adapter to know when the server is
unreachable:
const stores = createSupabaseStores<Database>({
supabase,
tables: ['todos'],
persistence: { adapter: new LocalStorageAdapter() },
network: new WebNetworkStatus(),
offlineQueue: { queueWrites: true },
})
// Offline, this resolves rather than throwing — the mutation is queued
const row = await stores.todos.getState().insert({ title: 'Offline todo' })
// …so the promise no longer tells you whether the server has it. The row does:
row._anchor_pending // 'insert' while queued, undefined once confirmed
stores.todos.getState().getQueueSize() // 1
await stores.todos.getState().flushQueue()That last point is the reason the option is opt-in rather than the default. Without it, a
write that cannot reach the server rejects: a caller's catch fires and the optimistic row
disappears. With it, the same write resolves, and code that reads a resolved promise as
"the server has it" will report success for something only queued. Check _anchor_pending
on what the mutator returns.
insert, update, upsert and remove queue. insertMany and removeWhere do not, and
still roll back and throw — see their notes in createTableStore.ts.
The queue supports:
- Coalescing — insert+update becomes single insert; insert+delete cancels both
- Dependency tracking —
dependsOnfield ensures parent mutations complete before children - Exponential backoff — retries with
base * 2^attempt + jitter - Rollback — a write abandoned past
maxRetriesrestores the row's previous state
Five built-in strategies, configurable per table:
import { remoteWins, localWins, lastWriteWins, fieldLevelMerge } from '@drakkar.software/anchor'
createTableStore({
// ...
conflict: {
strategy: 'last-write-wins',
timestampColumn: 'updated_at',
},
})
// Or field-level merge:
createTableStore({
// ...
conflict: {
strategy: 'field-merge',
serverOwnedFields: ['computed_score'], // Always use server value
clientOwnedFields: ['draft_content'], // Always use local value
},
})
// Or custom resolver:
createTableStore({
// ...
conflict: {
strategy: 'custom',
resolver: (local, remote, context) => ({
...remote,
title: local.title, // Keep local title, use remote for rest
}),
},
})Validate data before mutations:
import { zodValidator } from '@drakkar.software/anchor'
import { z } from 'zod'
const todoSchema = z.object({
title: z.string().min(1, 'Title required'),
completed: z.boolean(),
})
createTableStore({
// ...
validate: {
insert: zodValidator(todoSchema),
update: zodValidator(todoSchema.partial()),
},
})The library handles concurrent operations safely:
- Concurrent fetch(): Uses a generation counter — stale responses from superseded fetches are discarded automatically
- Concurrent mutations: Uses compare-and-swap (CAS) rollback with
_anchor_mutationId— a failed update only rolls back if its own optimistic write is still current, preventing it from destroying a concurrent successful mutation's data - Realtime during mutations: Rows with
_anchor_pendingmetadata are protected from being overwritten by realtime INSERT/UPDATE/DELETE events - Cross-tab sync: Pending optimistic rows are preserved when receiving state from other tabs
- Offline queue: Flush uses a
flushingguard to prevent concurrent execution, and in-place pruning preserves mutations enqueued during a flush
Session-gated stores with automatic clear/refetch:
import { setupAuthGate, isRlsError } from '@drakkar.software/anchor'
const cleanup = setupAuthGate(supabase, stores.auth, [stores.todos, stores.profiles], {
clearOnSignOut: true, // Clear all stores when user signs out
refetchOnSignIn: true, // Refetch all stores when user signs in
})The auth store owns the session and the five actions that change who is signed in. The flows around it are free functions, for code with no store to read — or no React at all:
import {
getSession, getUser,
signUpWithPassword, signInWithPassword,
updateUser, resendOtp,
verifyOtp, sendPasswordRecovery,
} from '@drakkar.software/anchor'
// Sign up, then confirm from the emailed code
const { user, session } = await signUpWithPassword(supabase, {
email, password, options: { emailRedirectTo: getWebAuthRedirectTo() },
})
// `session` is null when the project requires confirmation — not an error.
await verifyOtp(supabase, { email, token: code, type: 'signup' })
await resendOtp(supabase, { type: 'signup', email }) // "I didn't get the code"
// Change a password, after re-verifying the current one
await signInWithPassword(supabase, { email, password: currentPassword })
await updateUser(supabase, { password: newPassword })None of them throws: each returns its result beside an error wrapped as an
AnchorError, so a caller branches on .code rather than on message prose.
Delta fetch — only get rows changed since last sync:
import { incrementalSync } from '@drakkar.software/anchor'
const { fetchedCount, mergedCount } = await incrementalSync(
supabase, 'todos', 'id', stores.todos,
{ timestampColumn: 'updated_at' },
)Stale-while-revalidate pattern:
import { fetchWithSwr, setupAutoRevalidation, isStale } from '@drakkar.software/anchor'
// Serve stale data, refetch in background
await fetchWithSwr(stores.todos, { staleTTL: 5 * 60 * 1000 })
// Auto-revalidate every minute
const cleanup = setupAutoRevalidation(stores.todos, {
staleTTL: 5 * 60 * 1000,
checkInterval: 60 * 1000,
})Control how fetch() handles existing records — replace all (default) or merge into the cache:
// Store-level: all fetches accumulate records
const todosStore = createTableStore({
supabase,
table: 'todos',
cacheStrategy: 'merge', // 'replace' (default) | 'merge'
})
// In merge mode:
// - `records` accumulates all seen data (cache)
// - `order` reflects only the latest query (view)
await todosStore.getState().fetch() // records: [1,2,3], order: [1,2,3]
await todosStore.getState().fetch({ filters: [eq('completed', true)] }) // records: [1,2,3], order: [2]
// Per-fetch override
await store.getState().fetch({
filters: [eq('status', 'active')],
cacheStrategy: 'merge', // override for this call only
})
// Invalidate accumulated cache
await store.getState().clearAndFetch()Also available on createSupabaseStores(), globally and per relation through
tableOptions / viewOptions.
State changes sync across browser tabs:
import { setupCrossTabSync } from '@drakkar.software/anchor'
const cleanup = setupCrossTabSync(store, 'todos')
// Uses BroadcastChannel, falls back to localStorage eventsOr enable via store options:
createTableStore({
// ...
crossTab: { enabled: true },
})Auto-flush queue, refresh auth, and revalidate stale data when the app returns to the foreground:
import { setupAppLifecycle } from '@drakkar.software/anchor'
import { WebAppLifecycle } from '@drakkar.software/anchor-adapter-web'
// or: import { RNAppLifecycle } from '@drakkar.software/anchor-adapter-react-native'
const cleanup = setupAppLifecycle({
adapter: new WebAppLifecycle(),
stores: [stores.todos, stores.profiles],
authStore: stores.auth,
queue: offlineQueue,
flushQueueOnForeground: true, // default
refreshAuthOnForeground: true, // default
revalidateOnForeground: true, // default
pauseRealtimeOnBackground: false, // default
staleTTL: 5 * 60 * 1000, // 5 minutes
})Or use the React hook:
import { useAppLifecycle } from '@drakkar.software/anchor'
useAppLifecycle({
adapter: new WebAppLifecycle(),
stores: [stores.todos],
authStore: stores.auth,
})Flush the offline queue in the background on mobile:
import { setupBackgroundSync } from '@drakkar.software/anchor'
import { RNBackgroundSync } from '@drakkar.software/anchor-adapter-react-native'
const cleanup = await setupBackgroundSync(offlineQueue, new RNBackgroundSync())
// Cleanup: await cleanup()Sync state across devices via Supabase Realtime broadcast:
import { setupMultiDeviceSync } from '@drakkar.software/anchor'
const cleanup = setupMultiDeviceSync(supabase, {
todos: stores.todos,
profiles: stores.profiles,
}, {
conflict: { strategy: 'last-write-wins', timestampColumn: 'updated_at' },
debounceMs: 1000,
})Sync only relevant subsets of data:
import { selectiveSync, syncAllByPriority } from '@drakkar.software/anchor'
// Sync only active todos
await selectiveSync(supabase, 'todos', 'id', stores.todos, {
filters: [eq('status', 'active')],
timestampColumn: 'updated_at',
})
// Sync stores in priority order
await syncAllByPriority([
{ store: stores.todos, priority: 1 },
{ store: stores.profiles, priority: 2 },
])Transparently encrypt persisted data:
import { EncryptedAdapter, createWebCryptoEncryption } from '@drakkar.software/anchor'
import { LocalStorageAdapter } from '@drakkar.software/anchor-adapter-web'
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
const adapter = new EncryptedAdapter(new LocalStorageAdapter(), createWebCryptoEncryption(key))
createSupabaseStores({ persistence: { adapter } })Monitor and manage storage usage:
import { StorageQuotaManager } from '@drakkar.software/anchor'
const quota = new StorageQuotaManager()
const { count, estimatedBytes } = await quota.getUsage(adapter)
quota.setTableLimit('todos', 1000)
await quota.enforceLimit(adapter, 'todos')Automatic cache invalidation on schema changes:
import { checkSchemaVersion } from '@drakkar.software/anchor'
const { versionChanged } = await checkSchemaVersion(adapter, 2)
// If version changed, all cached data is cleared and fetch() repopulates from SupabaseMonitor sync status across stores:
import { useSyncStatus, useQueueStatus, usePendingChanges } from '@drakkar.software/anchor'
function SyncBar() {
const { status, pendingCount } = useSyncStatus([stores.todos, stores.profiles])
// status: 'synced' | 'syncing' | 'offline' | 'error'
return <div>{status} ({pendingCount} pending)</div>
}
function QueueInfo() {
const { pendingCount, queueSize } = useQueueStatus(stores.todos)
const pending = usePendingChanges(stores.todos)
// pending: [{ id, row, mutationType: 'insert' | 'update' | 'delete' }]
}Track sync health for monitoring:
import { SyncMetrics } from '@drakkar.software/anchor'
const metrics = new SyncMetrics()
createSupabaseStores({ logger: metrics })
const snap = metrics.getMetrics()
// snap.fetchLatencyP95, snap.mutationErrorCount, snap.conflictCount, ...Log and react to conflict resolutions:
import { ConflictAuditLog } from '@drakkar.software/anchor'
const auditLog = new ConflictAuditLog()
auditLog.onConflict((entry) => {
console.warn(`Conflict on ${entry.table}#${entry.rowId}: ${entry.strategy}`)
})
const log = auditLog.getLog({ table: 'todos', since: Date.now() - 60000 })Wrap any async operation with exponential backoff and jitter:
import { withRetry } from '@drakkar.software/anchor'
const result = await withRetry(() => createRpcAction(supabase, 'heavy_query')(), {
maxRetries: 3,
baseDelay: 1000,
})Protect against cascading failures from repeatedly calling failing endpoints:
import { CircuitBreaker } from '@drakkar.software/anchor'
const breaker = new CircuitBreaker({ failureThreshold: 5, resetTimeout: 30000 })
const result = await breaker.execute(() => fetch('/api/unstable'))
// After 5 failures: throws immediately without calling the function
// After 30s: allows one probe request (half-open state)Throttle requests using a token bucket algorithm:
import { RateLimiter } from '@drakkar.software/anchor'
const limiter = new RateLimiter({ maxTokens: 10, refillRate: 2 }) // 10 burst, 2/sec refill
if (limiter.tryConsume()) {
await fetch('/api/resource')
}Client-side and server-side aggregation:
import { aggregateLocal, aggregateRpc } from '@drakkar.software/anchor'
// Client-side (on store data)
const stats = aggregateLocal(stores.todos, {
total: 'count',
avgPriority: { op: 'avg', column: 'priority' },
maxPriority: { op: 'max', column: 'priority' },
})
// Server-side (via Postgres function)
const serverStats = await aggregateRpc(supabase, 'aggregate_todos', { user_id: '123' })Full Supabase Storage support:
import { createStorageActions } from '@drakkar.software/anchor'
const avatars = createStorageActions(supabase, 'avatars')
await avatars.upload('user-123.png', file, { upsert: true })
const url = avatars.getPublicUrl('user-123.png')
const { signedUrl } = await avatars.createSignedUrl('private/doc.pdf', { expiresIn: 3600 })
const files = await avatars.list('uploads/')
await avatars.remove(['old-file.png'])import { createEdgeFunctionAction } from '@drakkar.software/anchor'
const sendEmail = createEdgeFunctionAction<{ success: boolean }>(supabase, 'send-email')
const result = await sendEmail({ body: { to: 'user@example.com', subject: 'Hello' } })createSchemaRpc reads the function names, their arguments and their return
types out of the generated Database, so none of the three is written twice:
import { createSchemaRpc } from '@drakkar.software/anchor'
const rpc = createSchemaRpc<Database>(supabase) // supabase must be typed too
const { data, error } = await rpc('get_dashboard_stats', { user_id: '123' })
// ^? the function's own Returns type
await rpc('get_dashboard_stats') // error: the function declares arguments
await rpc('no_such_function') // error: not in the schema
await rpc('current_user_is_admin') // fine: generated as `Args: never`Pass the schema as the second type argument for a Database with no public
key: createSchemaRpc<Database, 'app'>(supabase).
callRpc and createRpcAction take the name as a plain string and the return
type as a type argument. They still work, and are what to use for a function the
generated types do not cover:
import { createRpcAction } from '@drakkar.software/anchor'
const getStats = createRpcAction<DashboardStats>(supabase, 'get_dashboard_stats')
const { data, error } = await getStats({ user_id: '123' })Server-side prefetch for React Server Components:
// app/todos/page.tsx (Server Component)
import { prefetch } from '@drakkar.software/anchor'
export default async function TodosPage() {
const { data } = await prefetch<Todo>(supabase, 'todos', {
sort: [{ column: 'created_at', ascending: false }],
limit: 50,
})
return <TodoList initialData={data} />
}import {
LocalStorageAdapter, IndexedDBAdapter,
WebNetworkStatus, WebAppLifecycle,
} from '@drakkar.software/anchor-adapter-web'
new LocalStorageAdapter() // Small datasets (<5MB)
new IndexedDBAdapter() // Large datasets
new WebNetworkStatus() // Network detection
new WebAppLifecycle() // App lifecycle (Page Visibility API)import {
ExpoSqliteAdapter, AsyncStorageAdapter,
RNNetworkStatus, RNAppLifecycle,
RNBackgroundSync, createExpoOAuthHandler,
} from '@drakkar.software/anchor-adapter-react-native'
new ExpoSqliteAdapter() // Structured (recommended)
new AsyncStorageAdapter() // Simple fallback
new RNNetworkStatus() // Network detection
new RNAppLifecycle() // App lifecycle (AppState API)
new RNBackgroundSync() // Background task (expo-task-manager)
createExpoOAuthHandler(supabase) // OAuth with deep linkscreateTableStore({
// ...
devtools: true,
// or with custom name:
devtools: { name: 'todos-store' },
})import { immer } from 'zustand/middleware/immer'
createTableStore({
// ...
immer, // Pass the middleware function
})| Package | Description |
|---|---|
@drakkar.software/anchor |
Core library |
@drakkar.software/anchor-adapter-web |
Web: localStorage, IndexedDB, WebNetworkStatus, WebAppLifecycle |
@drakkar.software/anchor-adapter-react-native |
React Native: expo-sqlite, AsyncStorage, NetInfo, AppLifecycle, BackgroundSync, OAuth |
// Full API
import { createTableStore, useQuery, eq } from '@drakkar.software/anchor'
// Hooks only
import { useQuery, useMutation, useSyncStatus } from '@drakkar.software/anchor/hooks'
// Client only
import { createAnchorClient, type AnchorClient } from '@drakkar.software/anchor/client'
// Auth actions only (no store, no React)
import { getSession, updateUser } from '@drakkar.software/anchor/auth/actions'
// Query builder only
import { query, QueryBuilder } from '@drakkar.software/anchor/query/queryBuilder'
// Server-only (no React dependency)
import { prefetch } from '@drakkar.software/anchor/server/prefetch'
// Storage only
import { createStorageActions } from '@drakkar.software/anchor/storage/storageActions'
// New entry points
import { setupAppLifecycle } from '@drakkar.software/anchor/lifecycle'
import { setupBackgroundSync } from '@drakkar.software/anchor/sync/background'
import { selectiveSync } from '@drakkar.software/anchor/sync/selective'
import { setupMultiDeviceSync } from '@drakkar.software/anchor/sync/multiDevice'
import { SyncMetrics } from '@drakkar.software/anchor/sync/metrics'
import { EncryptedAdapter } from '@drakkar.software/anchor/persistence/encrypted'
import { StorageQuotaManager } from '@drakkar.software/anchor/persistence/quota'
import { checkSchemaVersion } from '@drakkar.software/anchor/persistence/schemaVersion'
import { ConflictAuditLog } from '@drakkar.software/anchor/mutation/audit'
import { invokeEdgeFunction } from '@drakkar.software/anchor/functions'
import { fetchWithSwr } from '@drakkar.software/anchor/cache'
import { setupAuthGate } from '@drakkar.software/anchor/auth/gate'
import { buildCursorQuery } from '@drakkar.software/anchor/query/pagination'
import { incrementalSync } from '@drakkar.software/anchor/sync/incremental'Every entry point the build produces is reachable — src/__tests__/packageExports.test.ts
asserts it. Until 3.0.0 eight of the 26 were not, including three named in this
very list, and exports is an allowlist: a path missing from it throws
ERR_PACKAGE_PATH_NOT_EXPORTED however complete dist/ is.
- zustand >= 4.5.0
- @supabase/supabase-js — a dependency since 3.1.0, installed with Anchor.
You no longer name it yourself;
createAnchorClientcreates the client and the types it needs (SupabaseClient,Session,User,PostgrestError, …) are re-exported from the root. - TypeScript >= 5.0 (recommended)
- React >= 18.0 (optional, for hooks)
- immer (optional, for draft-based mutations)
MIT
