Skip to main content

Supastash Configuration

The configureSupastash() function sets up Supastash and must be called once at app startup

Quick Start

import { configureSupastash, defineLocalSchema } from "supastash";
import { supabase } from "./supabase";
import { openDatabaseAsync } from "expo-sqlite";

configureSupastash({
dbName: "supastash_db",
supabaseClient: supabase,
sqliteClient: { openDatabaseAsync },
sqliteClientType: "expo",
debugMode: true,
onSchemaInit: async () => {
defineLocalSchema("users", {
id: "TEXT PRIMARY KEY",
name: "TEXT",
email: "TEXT",
created_at: "TIMESTAMP DEFAULT CURRENT_TIMESTAMP",
updated_at: "TIMESTAMP DEFAULT CURRENT_TIMESTAMP",
});
},
// Recommended hardening for name<=20/barcode flows
syncPolicy: {
nonRetryableCodes: new Set([
"23505",
"23502",
"23514",
"23P01",
"22001",
"22P02",
]),
onNonRetryable: "accept-server",
},
});

Call once at app startup (before using any hooks).


What’s New (at a glance)

  • Merging rules clarified for syncEngine, excludeTables, pollingInterval, syncPolicy, and fieldEnforcement.
  • Conflict behavior flag: deleteConflictedRows (off by default). When true, non‑retryable conflicts delete local rows.
  • Policy plumbing: DEFAULT_POLICY and user policy merge; nonRetryableCodes/retryableCodes are replaced if provided (not unioned).
  • Field enforcement block with sensible defaults and auto‑fill options.
  • Optional useCustomRPCForUpserts flag for teams using RPC-based upserts.

API

configureSupastash<T>(config)

Initializes Supastash. Must be called once.

Type: SupastashConfig<T> & { sqliteClientType: T }

Core options

OptionTypeDefaultNotes
dbNamestring"supastash_db"Name of local SQLite DB.
supabaseClientSupabaseClient | nullrequiredA configured Supabase client.
sqliteClientadapter for chosen enginerequiredShape depends on sqliteClientType (see below).
sqliteClientType"expo" | "rn-storage" | "rn-nitro" | nullrequiredSelects SQLite engine.
onSchemaInit() => Promise<void>undefinedOptional hook to define local schema with defineLocalSchema. Runs once after DB creation.
debugModebooleantrueVerbose logs for sync/DB.
listenersnumber250Max Realtime listeners.

Sync switches & intervals

OptionTypeDefaultNotes
syncEngine.pushbooleantruePush local changes to Supabase.
syncEngine.pullbooleantrueEnabled by default in code. Only keep on if you’ve secured tables with RLS and/or use useSupastashFilters.ts for filtered pulls.
syncEngine.useFiltersFromStorebooleantrueApplies filters captured by hooks to background pulls.
pollingInterval.pullnumber30000ms between pull polls.
pollingInterval.pushnumber30000ms between push polls.

Recommendation: If your RLS isn’t airtight, set syncEngine.pull = false globally and rely on per‑screen filtered pulls via useSupastashFilters.ts.

Table selection

OptionTypeDefaultNotes
excludeTables.pullstring[][]Don’t pull these tables.
excludeTables.pushstring[][]Don’t push these tables.

Conflict policy & field enforcement

OptionTypeDefaultNotes
syncPolicySupastashSyncPolicyDEFAULT_POLICYSee Conflict Policy below.
fieldEnforcementFieldEnforcementDEFAULT_FIELDSSee Field Enforcement below.
deleteConflictedRowsbooleanfalseWhen true, non‑retryable conflicts will delete the local row.

Behavior & Precedence (how merges work)

configureSupastash builds the final config like this:

  • syncEngine: shallow-merged. Provided keys override defaults.
  • excludeTables: per-key fallback. If you pass only pull, existing push is preserved (and vice‑versa).
  • pollingInterval: per-key fallback (same pattern as excludeTables).
  • syncPolicy: base = DEFAULT_POLICY → then old _config.syncPolicy → then your config.syncPolicy. For the two sets inside policy:
    • nonRetryableCodes: replaced by your provided set if given (no union).
    • retryableCodes: replaced by your provided set if given (no union).
  • fieldEnforcement: base DEFAULT_FIELDS → old → your overrides (shallow merge).

Practical upshot: if you want to add codes, you must supply a full set including the defaults you still want.


Conflict Policy (DEFAULT_POLICY)

nonRetryableCodes: new Set(["23505","23502","23514","23P01"]),
retryableCodes: new Set(["40001","40P01","55P03"]),
fkCode: "23503",
onNonRetryable: "accept-server",
maxTransientMs: 20 * 60 * 1000,
maxFkBlockMs: 24 * 60 * 60 * 1000,
backoffDelaysMs: [10_000, 30_000, 120_000, 300_000, 600_000],
maxBatchAttempts: 5,

Interpretation

  • 23505 (unique), 23502 (not null), 23514 (check), 23P01 (exclusion) → NON_RETRYABLE.
  • 23503 (FK) → FK_BLOCK (held up to maxFkBlockMs).
  • 40001, 40P01, 55P03RETRYABLE (backoff per backoffDelaysMs, within maxTransientMs).
  • onNonRetryable: "accept-server" → default server‑wins resolution.

Row deletion switch

  • If you want local rows actually deleted on non‑retryable conflicts, either:
    • set syncPolicy.onNonRetryable = "delete-local", or
    • set top‑level deleteConflictedRows = true (your handler respects this).

Recommended extra codes for prod

nonRetryableCodes: new Set([
"23505",
"23502",
"23514",
"23P01", // defaults
"22001", // string_data_right_truncation (name length caps)
"22P02", // invalid_text_representation (bad UUID/number)
]);

Field Enforcement (DEFAULT_FIELDS)

requireCreatedAt: true,
requireUpdatedAt: true,
createdAtField: "created_at",
updatedAtField: "updated_at",
autoFillMissing: true,
autoFillDefaultISO: "1970-01-01T00:00:00Z",
  • Enforces timestamp presence on all synced tables.
  • When autoFillMissing is true, absent values are backfilled with autoFillDefaultISO.
  • You can rename the columns (e.g., created/modified).

SQLite Client Types

TypeProvideNotes
"expo"{ openDatabaseAsync }Simple & stable for most apps.
"rn-nitro"{ open }Best performance for large datasets.
"rn-storage"{ openDatabase }Legacy; widely supported.

Adapter contract The adapter you pass must ultimately satisfy SupastashSQLiteAdapter.openDatabaseAsync(name, sqliteClient) and return a SupastashSQLiteDatabase implementing runAsync, getAllAsync, getFirstAsync, and execAsync.


Recipes

Enforce unique name (≤20 chars) per shop; barcodes unique per shop

configureSupastash({
syncPolicy: {
nonRetryableCodes: new Set(["23505", "23502", "23514", "23P01", "22001"]),
onNonRetryable: "accept-server",
},
});

Server Postgres unique indexes:

create extension if not exists unaccent;

create unique index items_name_shop_uq on items (
shop_id,
lower(regexp_replace(unaccent(name), '\\s+', ' ', 'g'))
) where deleted_at is null;

create unique index items_barcode_shop_uq on items (
shop_id, barcode
) where deleted_at is null and barcode is not null;

Delete conflicted rows automatically

configureSupastash({
deleteConflictedRows: true,
syncPolicy: {
onNonRetryable: "delete-local",
},
});

Override polling & exclude tables

configureSupastash({
pollingInterval: { pull: 60_000, push: 15_000 },
excludeTables: { pull: ["audit_logs"], push: ["snapshots"] },
});

Use Nitro SQLite

import { open } from "react-native-nitro-sqlite";
configureSupastash({ sqliteClientType: "rn-nitro", sqliteClient: { open } });

Troubleshooting & FAQs

Q: Will rows be deleted locally on conflicts?
A: Only if you set deleteConflictedRows: true or syncPolicy.onNonRetryable = 'delete-local'. Otherwise the handler accepts the server row and stops retrying.

Q: Do my custom error-code sets merge with defaults?
A: No. If you supply nonRetryableCodes/retryableCodes, they replace the sets. Include defaults plus your additions.

Q: Is pull enabled by default?
A: In the current code, yes (syncEngine.pull = true). If you want extra safety, set it to false and rely on useSupastashFilters.ts for filtered pulls or enable only after RLS is tight.

Q: Can I push via a custom RPC?
A: Yes—toggle useCustomRPCForUpserts: true and implement your RPC path in your sync layer.


Breaking/Behavior Notes

  • Policy sets are replacing, not unioning.
  • excludeTables/pollingInterval keys use per‑key fallback; you can set just one without losing the other.
  • fieldEnforcement defaults mandate timestamps; disable or rename if your schema differs.

Reference Types (abridged)

export interface SupastashSyncPolicy {
nonRetryableCodes?: Set<string>;
retryableCodes?: Set<string>;
fkCode?: string; // default '23503'
onNonRetryable?: "accept-server" | "delete-local";
maxTransientMs?: number; // default 20m
maxFkBlockMs?: number; // default 24h
backoffDelaysMs?: number[]; // default [10s,30s,120s,300s,600s]
maxBatchAttempts?: number; // default 5
ensureParents?: (table: string, row: any) => Promise<"ok" | "blocked">;
onRowAcceptedServer?: (table: string, id: string) => void;
onRowDroppedLocal?: (table: string, id: string) => void;
}

export interface FieldEnforcement {
requireCreatedAt?: boolean; // default true
requireUpdatedAt?: boolean; // default true
createdAtField?: string; // default 'created_at'
updatedAtField?: string; // default 'updated_at'
autoFillMissing?: boolean; // default true
autoFillDefaultISO?: string; // default '1970-01-01T00:00:00Z'
}

Final Advice

  • Keep pull on only with solid RLS and/or filtered pulls.
  • Add 22001 and 22P02 to nonRetryableCodes for tighter UX.
  • Prefer server‑wins for names; prompt users for barcode conflicts.
  • If you truly need auto‑cleanups, enable deleteConflictedRows and instrument onRowDroppedLocal for visibility.