supastash
Version:
Offline-first sync engine for Supabase in React Native using SQLite
103 lines (102 loc) • 5.09 kB
JavaScript
import { getSupastashConfig } from "../../../core/config";
import { getSupastashDb } from "../../../db/dbInitializer";
import { generateUUIDv4 } from "../../../utils/genUUID";
import { logError, logWarn } from "../../logs";
import { getSafeValue } from "../../serializer";
import { parseStringifiedFields } from "../../sync/pushLocal/parseFields";
import { assertTableExists } from "../../tableValidator";
const warned = new Set();
/**
* Performs upsert-like logic on local DB:
* - If a row with the same ID exists, it is updated.
* - Otherwise, it is inserted.
* Returns all the rows that were upserted.
*/
export async function upsertData(table, payload, syncMode, isSingle, onConflictKeys = ["id"], preserveTimestamp) {
if (!payload || !table)
throw new Error("Table and payload are required for upsert.");
await assertTableExists(table);
const timeStamp = new Date().toISOString();
const items = Array.isArray(payload) ? payload : [payload];
const upserted = [];
try {
const db = await getSupastashDb();
for (const item of items) {
const newPayload = {
...item,
synced_at: Object.prototype.hasOwnProperty.call(item, "synced_at")
? item.synced_at
: syncMode && (syncMode === "localOnly" || syncMode === "remoteFirst")
? timeStamp
: null,
};
const colArray = Object.keys(newPayload);
const includesConflictKeys = onConflictKeys.every((key) => colArray.includes(key));
if (!includesConflictKeys) {
throw new Error(`onConflictKeys must include at least one key from the payload. Payload: ${JSON.stringify(newPayload)}`);
}
const whereClause = onConflictKeys
.map((key) => `${key} = ?`)
.join(" AND ");
const conflictValues = onConflictKeys.map((key) => getSafeValue(newPayload[key]));
const existingData = await db.getAllAsync(`SELECT * FROM ${table} WHERE ${whereClause}`, [...conflictValues]);
if (existingData.length > 0) {
if (existingData.length > 1) {
throw new Error(`Multiple rows matched onConflictKeys in '${table}' — expected unique constraint. Payload: ${JSON.stringify(newPayload)}`);
}
if (!preserveTimestamp || item.updated_at === undefined) {
if (!warned.has(table) &&
!getSupastashConfig().debugMode &&
__DEV__) {
warned.add(table);
logWarn(`[Supastash] updated_at not provided for upsert call on ${table} – defaulting to ${timeStamp}`);
}
const userUpdatedAt = item.updated_at;
newPayload.updated_at =
userUpdatedAt !== undefined ? userUpdatedAt : timeStamp;
}
const updateColsArray = Object.keys(newPayload);
const updateCols = updateColsArray
.filter((col) => !onConflictKeys.includes(col))
.map((col) => `${col} = ?`)
.join(", ");
const updateValues = updateColsArray
.filter((col) => !onConflictKeys.includes(col))
.map((c) => getSafeValue(newPayload[c]));
const updateSql = `UPDATE ${table} SET ${updateCols} WHERE ${whereClause}`;
await db.runAsync(updateSql, [...updateValues, ...conflictValues]);
upserted.push(parseStringifiedFields(newPayload));
}
else {
const insertPayload = {
...newPayload,
id: newPayload.id ?? generateUUIDv4(),
created_at: newPayload.created_at ?? timeStamp,
updated_at: newPayload.updated_at ?? timeStamp,
};
const newColsArray = Object.keys(insertPayload);
const insertCols = newColsArray.join(", ");
const insertPlaceholders = newColsArray.map(() => "?").join(", ");
const insertValues = newColsArray.map((c) => getSafeValue(insertPayload[c]));
const insertSql = `INSERT INTO ${table} (${insertCols}) VALUES (${insertPlaceholders})`;
await db.runAsync(insertSql, insertValues);
const inserted = await db.getFirstAsync(`SELECT * FROM ${table} WHERE ${whereClause} LIMIT 1`, [...conflictValues]);
if (inserted)
upserted.push(parseStringifiedFields(inserted));
}
}
return {
error: null,
data: isSingle ? upserted[0] : upserted,
};
}
catch (error) {
logError(`[Supastash] ${error}`);
return {
error: {
message: error instanceof Error ? error.message : String(error),
},
data: null,
};
}
}