supastash
Version:
Offline-first sync engine for Supabase in React Native using SQLite
200 lines (199 loc) • 8.15 kB
JavaScript
import { getSupastashConfig } from "../../../../core/config";
import { getSupastashDb } from "../../../../db/dbInitializer";
import { generateUUIDv4 } from "../../../genUUID";
import { parseStringifiedFields as parseRow } from "../../../sync/pushLocal/parseFields";
import { queueRemoteCall } from "../queueRemote";
const CHECK_BATCH = 900; // param headroom under 999
const remoteCalls = ["localFirst", "remoteFirst", "remoteOnly"];
export async function upsertMany(items, opts, state) {
const db = await getSupastashDb();
const { table, syncMode, nowISO, preserveTimestamp = false, yieldEvery = 500, } = opts;
const returnRows = opts.returnRows !== false;
const onConflictKeys = opts.onConflictKeys && opts.onConflictKeys.length
? opts.onConflictKeys
: ["id"];
const config = getSupastashConfig();
const supabase = config?.supabaseClient;
if (!supabase) {
throw new Error("[Supastash] Supabase Client is required to perform this operation.");
}
assertTableName(table);
onConflictKeys.forEach(assertIdent);
if (!Array.isArray(items) || items.length === 0)
return [];
const timeStamp = nowISO ?? new Date().toISOString();
let existingIdSet = null;
if (onConflictKeys.length === 1 && onConflictKeys[0] === "id") {
const ids = items
.map((row, i) => {
const id = row?.id ?? null;
// allow missing; we’ll generate before insert
return id == null ? null : String(id);
})
.filter(Boolean);
existingIdSet = new Set(await selectIdsInBatches(db, table, ids));
}
const upserted = [];
const remotePayload = [];
const run = async () => {
for (let i = 0; i < items.length; i++) {
const input = items[i] ?? {};
const row = { ...input };
// Ensure id if it's part of conflict keys
if (onConflictKeys.includes("id") && (row.id == null || row.id === "")) {
row.id = generateUUIDv4();
}
// synced_at default
if (!hasOwn(row, "synced_at")) {
row.synced_at =
syncMode && (syncMode === "localOnly" || syncMode === "remoteFirst")
? timeStamp
: null;
}
// Decide: update or insert?
const { clause, values: keyValues } = buildWhere(onConflictKeys, row);
const canCheckConflict = clause !== null;
let exists = false;
if (onConflictKeys.length === 1 &&
onConflictKeys[0] === "id" &&
existingIdSet) {
exists = existingIdSet.has(String(row.id));
}
else if (canCheckConflict) {
const existing = await db.getAllAsync(`SELECT 1 FROM ${quote(table)} WHERE ${clause} LIMIT 2`, keyValues);
if (existing.length > 1) {
throw new Error(`Multiple rows matched onConflictKeys in '${table}' — expected uniqueness on ${onConflictKeys.join(", ")}`);
}
exists = existing.length === 1;
}
if (exists) {
// UPDATE path
if (!preserveTimestamp || input.updated_at === undefined) {
row.updated_at =
input.updated_at !== undefined ? input.updated_at : timeStamp;
}
// Build SET list (exclude conflict keys; also skip undefined to avoid nulling unintentionally)
const updateCols = Object.keys(row).filter((c) => !onConflictKeys.includes(c) && row[c] !== undefined);
if (updateCols.length > 0) {
const setSql = updateCols.map((c) => `${quote(c)} = ?`).join(", ");
const setVals = updateCols.map((c) => toDbValue(row[c]));
if (!canCheckConflict) {
throw new Error(`Missing onConflictKeys in payload; cannot UPDATE in '${table}'. Keys: ${onConflictKeys.join(", ")}`);
}
await db.runAsync(`UPDATE ${quote(table)} SET ${setSql} WHERE ${clause}`, [...setVals, ...keyValues]);
remotePayload.push(row);
if (returnRows) {
const updated = await db.getFirstAsync(`SELECT * FROM ${quote(table)} WHERE ${clause} LIMIT 1`, keyValues);
if (returnRows)
upserted.push(parseRow(updated));
}
}
}
else {
// INSERT path
if (!hasOwn(row, "created_at"))
row.created_at = timeStamp;
if (!hasOwn(row, "updated_at"))
row.updated_at = timeStamp;
if (!hasOwn(row, "id"))
row.id = generateUUIDv4();
const insertCols = Object.keys(row).filter((c) => row[c] !== undefined);
const placeholders = insertCols.map(() => "?").join(", ");
const insertVals = insertCols.map((c) => toDbValue(row[c]));
remotePayload.push(row);
await db.runAsync(`INSERT INTO ${quote(table)} (${insertCols
.map(quote)
.join(", ")}) VALUES (${placeholders})`, insertVals);
if (returnRows) {
const inserted = await db.getFirstAsync(`SELECT * FROM ${quote(table)} WHERE ${clause} LIMIT 1`, keyValues);
upserted.push(parseRow(inserted));
}
if (existingIdSet &&
onConflictKeys.length === 1 &&
onConflictKeys[0] === "id") {
existingIdSet.add(String(row.id));
}
}
if (yieldEvery > 0 && (i + 1) % yieldEvery === 0) {
await microYield();
}
}
};
try {
await run();
const newState = { ...state, payload: remotePayload };
if (remoteCalls.includes(newState.type)) {
queueRemoteCall(newState);
}
}
catch (e) {
throw e;
}
return returnRows ? upserted : undefined;
}
/* ================= helpers ================= */
function buildWhere(keys, row) {
if (!keys.length)
return { clause: null, values: [] };
const parts = [];
const vals = [];
for (const k of keys) {
if (!(k in row)) {
return { clause: null, values: [] };
}
const v = row[k];
if (v === null) {
parts.push(`${quote(k)} IS NULL`);
}
else {
parts.push(`${quote(k)} = ?`);
vals.push(toDbValue(v));
}
}
return { clause: parts.join(" AND "), values: vals };
}
function toDbValue(v) {
if (v === undefined)
return null;
if (v === null)
return null;
if (v instanceof Date)
return v.toISOString();
if (Array.isArray(v))
return JSON.stringify(v);
if (typeof v === "object")
return JSON.stringify(v);
return v;
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function assertTableName(name) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`Unsafe table name: ${name}`);
}
}
function assertIdent(name) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`Unsafe identifier: ${name}`);
}
}
function quote(name) {
return `"${name}"`;
}
async function selectIdsInBatches(db, table, ids) {
const out = [];
for (let i = 0; i < ids.length; i += CHECK_BATCH) {
const part = ids.slice(i, i + CHECK_BATCH);
if (part.length === 0)
continue;
const ph = Array(part.length).fill("?").join(",");
const rows = await db.getAllAsync(`SELECT id FROM ${quote(table)} WHERE id IN (${ph})`, part);
for (const r of rows)
out.push(String(r.id));
}
return out;
}
function microYield() {
return new Promise((res) => setTimeout(res, 0));
}