supastash
Version:
Offline-first sync engine for Supabase in React Native using SQLite
96 lines (95 loc) • 4.35 kB
JavaScript
import { getSupastashDb } from "../../db/dbInitializer";
import { clearSchemaCache } from "../../utils/getTableSchema";
import log, { logError } from "../../utils/logs";
/**
* 🧱 defineLocalSchema
*
* Defines a local SQLite table schema programmatically, with support for foreign keys and indices.
* Intended for offline-first apps using Supastash. Ensures consistency in structure and indexing while
* allowing runtime control of schema migration through `deletePreviousSchema`.
*
* ---
*
* @param tableName - The name of the local SQLite table.
* @param schema - The column definitions (e.g. `{ id: "TEXT NOT NULL", name: "TEXT" }`) and optional metadata:
* - `__indices`: Column names to be indexed.
* @param deletePreviousSchema - If `true`, drops the existing table and related Supastash metadata before re-creating.
* ⚠️ WARNING: If left `true` in production, the table will be dropped and re-created **on every load**.
*
* ---
*
* @example
* defineLocalSchema("users", {
* id: "TEXT PRIMARY KEY",
* name: "TEXT NOT NULL",
* email: "TEXT",
* user_id: "TEXT NOT NULL",
* __indices: ["email"]
* }, true);
*
* @remarks
* - Automatically injects `created_at`, `updated_at`, `deleted_at`, and `synced_at` columns.
* - Requires an `id` column to exist.
* - Validates all foreign keys and index columns exist in the schema before applying.
*/
export async function defineLocalSchema(tableName, schema, deletePreviousSchema = false) {
try {
if (!schema.id) {
throw new Error(`'id' of type UUID column is required for table ${tableName}`);
}
const db = await getSupastashDb();
const { __indices, ...columnSchema } = schema;
const indexNotInSchema = __indices?.some((i) => !columnSchema[i]);
if (__indices && indexNotInSchema) {
throw new Error(`Index ${indexNotInSchema} not found in schema. Please ensure all indices are defined in the schema.`);
}
// Ensure required columns
const safeSchema = {
...columnSchema,
created_at: columnSchema.created_at ?? "TEXT NOT NULL",
updated_at: columnSchema.updated_at ?? "TEXT NOT NULL",
synced_at: "TEXT DEFAULT NULL",
deleted_at: columnSchema.deleted_at ?? "TEXT DEFAULT NULL",
};
// Build column definitions
const schemaParts = Object.entries(safeSchema).map(([key, value]) => `${key} ${value}`);
const schemaString = schemaParts.join(", ");
const sql = `CREATE TABLE IF NOT EXISTS ${tableName} (${schemaString});`;
if (deletePreviousSchema) {
const dropSql = `DROP TABLE IF EXISTS ${tableName}`;
const clearSyncStatusSql = `DELETE FROM supastash_sync_status WHERE table_name = '${tableName}'`;
const clearDeleteStatusSql = `DELETE FROM supastash_deleted_status WHERE table_name = '${tableName}'`;
const clearLastCreatedStatusSql = `DELETE FROM supastash_last_created WHERE table_name = '${tableName}'`;
const tryDropTable = async (attempt = 1) => {
try {
await db.execAsync(dropSql);
}
catch (err) {
if (String(err).includes("table is locked") && attempt < 5) {
await new Promise((res) => setTimeout(res, attempt * 100));
return tryDropTable(attempt + 1);
}
throw err;
}
};
await tryDropTable();
await db.execAsync(clearSyncStatusSql);
await db.execAsync(clearDeleteStatusSql);
await db.execAsync(clearLastCreatedStatusSql);
clearSchemaCache(tableName);
log(`[Supastash] Dropped table ${tableName}`);
}
await db.execAsync(sql);
// Generate and create index SQL
if (__indices?.length) {
for (const col of __indices) {
const indexName = `idx_${tableName}_${col}`;
const indexSql = `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${col});`;
await db.execAsync(indexSql);
}
}
}
catch (error) {
logError(`[Supastash] Error defining schema for table ${tableName}`, error);
}
}