supastash
Version:
Offline-first sync engine for Supabase in React Native using SQLite
142 lines (141 loc) • 5.85 kB
JavaScript
import { getSupastashDb } from "../../db/dbInitializer";
import { clearSchemaCache } from "../../utils/getTableSchema";
import log, { logError } from "../../utils/logs";
import { resetSupastashSyncStatus } from "../../utils/sync/status/services";
/**
* 🧱 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`.
*
* It will also create the following indexes:
* - synced_at
* - deleted_at
* - created_at
* - updated_at
* if they do not exist in the schema and if columns exist in the table.
* ---
*
* @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, __constraints, ...columnSchema } = schema;
const indexNotInSchema = __indices?.filter((i) => !columnSchema[i]) ?? [];
if (indexNotInSchema.length > 0) {
throw new Error(`Index columns ${indexNotInSchema.join(", ")} not found in schema. Please ensure all columns 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} (${__constraints ? `${schemaString}, ${__constraints}` : schemaString});`;
if (deletePreviousSchema) {
const dropSql = `DROP TABLE IF EXISTS ${tableName}`;
const tryDropTable = async (attempt = 1) => {
try {
await db.execAsync(dropSql);
await resetSupastashSyncStatus(tableName, undefined, "all");
}
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();
clearSchemaCache(tableName);
log(`[Supastash] Dropped table ${tableName}`);
}
await db.execAsync(sql);
const standardIndexes = [
"synced_at",
"deleted_at",
"created_at",
"updated_at",
];
// Generate and create index SQL
if (__indices?.length) {
for (const col of __indices) {
if (standardIndexes.includes(col)) {
continue;
}
const indexName = `idx_${tableName}_${col}`;
const indexSql = `CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${col});`;
await db.execAsync(indexSql);
}
}
await createStandardIndexes(db, tableName, standardIndexes);
}
catch (error) {
logError(`[Supastash] Error defining schema for table ${tableName}`, error);
}
}
async function createStandardIndexes(db, table, columns) {
const pragmaRows = await db.getAllAsync(`PRAGMA table_info(${table});`);
const existingCols = Array.isArray(pragmaRows)
? pragmaRows.map((r) => r.name)
: [];
try {
for (const col of columns) {
if (existingCols.includes(col)) {
const hasSameIndex = await hasSingleColumnIndex(db, table, col);
if (!hasSameIndex) {
await db.execAsync(`CREATE INDEX IF NOT EXISTS idx_${table}_${col} ON ${table}(${col});`);
}
}
}
}
catch (error) {
logError(`[Supastash] Error creating standard indexes for ${table}`, error);
}
}
async function hasSingleColumnIndex(db, table, col) {
const idxList = await db.getAllAsync(`PRAGMA index_list(${table});`);
if (!Array.isArray(idxList))
return false;
for (const idx of idxList) {
const idxName = idx.name;
const info = await db.getAllAsync(`PRAGMA index_info(${idxName});`);
if (!Array.isArray(info))
continue;
// Single-column index exactly on `col`
if (info.length === 1 && info[0]?.name === col)
return true;
}
return false;
}