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, andfieldEnforcement. - Conflict behavior flag:
deleteConflictedRows(off by default). Whentrue, non‑retryable conflicts delete local rows. - Policy plumbing:
DEFAULT_POLICYand user policy merge;nonRetryableCodes/retryableCodesare replaced if provided (not unioned). - Field enforcement block with sensible defaults and auto‑fill options.
- Optional
useCustomRPCForUpsertsflag for teams using RPC-based upserts.
API
configureSupastash<T>(config)
Initializes Supastash. Must be called once.
Type: SupastashConfig<T> & { sqliteClientType: T }
Core options
| Option | Type | Default | Notes |
|---|---|---|---|
dbName | string | "supastash_db" | Name of local SQLite DB. |
supabaseClient | SupabaseClient | null | required | A configured Supabase client. |
sqliteClient | adapter for chosen engine | required | Shape depends on sqliteClientType (see below). |
sqliteClientType | "expo" | "rn-storage" | "rn-nitro" | null | required | Selects SQLite engine. |
onSchemaInit | () => Promise<void> | undefined | Optional hook to define local schema with defineLocalSchema. Runs once after DB creation. |
debugMode | boolean | true | Verbose logs for sync/DB. |
listeners | number | 250 | Max Realtime listeners. |
Sync switches & intervals
| Option | Type | Default | Notes |
|---|---|---|---|
syncEngine.push | boolean | true | Push local changes to Supabase. |
syncEngine.pull | boolean | true | Enabled by default in code. Only keep on if you’ve secured tables with RLS and/or use useSupastashFilters.ts for filtered pulls. |
syncEngine.useFiltersFromStore | boolean | true | Applies filters captured by hooks to background pulls. |
pollingInterval.pull | number | 30000 | ms between pull polls. |
pollingInterval.push | number | 30000 | ms between push polls. |
Recommendation: If your RLS isn’t airtight, set
syncEngine.pull = falseglobally and rely on per‑screen filtered pulls viauseSupastashFilters.ts.
Table selection
| Option | Type | Default | Notes |
|---|---|---|---|
excludeTables.pull | string[] | [] | Don’t pull these tables. |
excludeTables.push | string[] | [] | Don’t push these tables. |
Conflict policy & field enforcement
| Option | Type | Default | Notes |
|---|---|---|---|
syncPolicy | SupastashSyncPolicy | DEFAULT_POLICY | See Conflict Policy below. |
fieldEnforcement | FieldEnforcement | DEFAULT_FIELDS | See Field Enforcement below. |
deleteConflictedRows | boolean | false | When 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 onlypull, existingpushis preserved (and vice‑versa).pollingInterval: per-key fallback (same pattern asexcludeTables).syncPolicy: base =DEFAULT_POLICY→ then old_config.syncPolicy→ then yourconfig.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: baseDEFAULT_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 tomaxFkBlockMs).40001,40P01,55P03→ RETRYABLE (backoff perbackoffDelaysMs, withinmaxTransientMs).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).
- set
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
autoFillMissingistrue, absent values are backfilled withautoFillDefaultISO. - You can rename the columns (e.g.,
created/modified).
SQLite Client Types
| Type | Provide | Notes |
|---|---|---|
"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/pollingIntervalkeys use per‑key fallback; you can set just one without losing the other.fieldEnforcementdefaults 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
22001and22P02tononRetryableCodesfor tighter UX. - Prefer server‑wins for names; prompt users for barcode conflicts.
- If you truly need auto‑cleanups, enable
deleteConflictedRowsand instrumentonRowDroppedLocalfor visibility.