lift-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev f9f4e37d6945
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 23e397d8a5d5bf7756362640
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/lib/sync.ts
// Keeps the device copy of the log and the person's Bounded account in step, and carries a guest's
// log into their account when they choose to keep it on all devices.
// Docs: lifters/<uid> (settings, your exercises, body weight), lifters/<uid>/routines/<id>,
// lifters/<uid>/workouts/<id>. Each is one JSON string plus the server time it was written (at),
// so after the first sync a device reads only the docs that changed since, and writes only its own changes.
import { useSyncExternalStore } from "react";
import { get, setMany, serverTimestamp } from "@bounded-sh/client";
import { me, signIn, type Me } from "./bounded";
import { getState, replace, saveSync, onLocalChange, type State } from "./store";

const UPGRADE = "lift:upgrade";
const MARGIN = 300; // seconds read again before the newest change seen, for writes that landed while we read
let profileSent = ""; // the profile data last written or read
let user: Me | null = null;
let timer: number | undefined;
let ready: Promise<Me> | null = null;
let upgraded = false;
let pulledAt = 0; // a push waits until the account's copy has been read once, so it never overwrites newer data
let readyUser: Me | null = null;
const readyListeners = new Set<() => void>();

const profileData = (s: State) => JSON.stringify({ settings: s.settings, custom: s.custom, bw: s.bw });
const syncOf = (uid: string) => { const x = getState().sync; return x?.uid === uid ? x : { uid, since: 0, at: {} }; };

type Stamped = { at: number };
function mergeById<T extends Stamped>(a: Record<string, T>, b: Record<string, T>): Record<string, T> {
  const out = { ...a };
  for (const [id, x] of Object.entries(b)) if (!out[id] || x.at > out[id].at) out[id] = x;
  return out;
}

function parse<T>(data: string): T | null {
  try { return JSON.parse(data) as T; } catch { return null; }
}

/** Every doc in a collection, or only those written after a server time. */
async function readAll(path: string, after: number): Promise<{ data: string; at: number }[]> {
  const rows: { data: string; at: number }[] = [];
  let cursor: string | undefined;
  do {
    const page = await get(path, { limit: 1000, cursor, ...(after > 0 ? { filter: { at: { $gt: after } } } : {}) });
    for (const r of page?.data || []) rows.push({ data: r.data, at: Number(r.at) || 0 });
    cursor = page?.nextCursor ?? undefined;
  } while (cursor);
  return rows;
}

async function pull(uid: string) {
  const base = `lifters/${uid}`;
  const known = syncOf(uid);
  const after = known.since ? known.since - MARGIN : 0;
  const [profile, routines, workouts] = await Promise.all([get(base), readAll(`${base}/routines`, after), readAll(`${base}/workouts`, after)]);
  const local = getState();
  const next: State = { ...local, owner: uid };
  if (profile?.data) {
    profileSent = profile.data;
    const p = parse<Pick<State, "settings" | "custom" | "bw">>(profile.data);
    if (p) {
      if ((p.settings?.at ?? 0) > local.settings.at) next.settings = { ...local.settings, ...p.settings };
      next.custom = mergeById(local.custom, p.custom || {});
      const bw = { ...local.bw };
      for (const [day, e] of Object.entries(p.bw || {})) if (!bw[day] || e.t > bw[day].t) bw[day] = e;
      next.bw = bw;
    }
  }
  const sync = { uid, since: known.since, at: { ...known.at } };
  const take = <T extends { id: string; at: number }>(rows: { data: string; at: number }[], kind: "routines" | "workouts") => {
    const out: Record<string, T> = {};
    for (const r of rows) {
      const x = parse<T>(r.data);
      if (!x) continue;
      out[x.id] = x;
      sync.at[`${base}/${kind}/${x.id}`] = x.at;
      sync.since = Math.max(sync.since, r.at);
    }
    return out;
  };
  next.routines = mergeById(local.routines, take(routines, "routines"));
  next.workouts = mergeById(local.workouts, take(workouts, "workouts"));
  replace({ ...next, sync });
  pulledAt = Date.now();
}

async function push() {
  if (!user || !pulledAt) return;
  const base = `lifters/${user.id}`;
  const s = getState();
  const sync = syncOf(user.id);
  const writes: { path: string; document: { data: string; at?: unknown }; at?: number }[] = [];
  const profile = profileData(s);
  if (profile !== profileSent) writes.push({ path: base, document: { data: profile } });
  for (const [kind, items] of [["routines", s.routines], ["workouts", s.workouts]] as const) {
    for (const x of Object.values(items)) {
      const path = `${base}/${kind}/${x.id}`;
      if (sync.at[path] !== x.at) writes.push({ path, document: { data: JSON.stringify(x), at: serverTimestamp() }, at: x.at });
    }
  }
  if (!writes.length) return;
  for (let i = 0; i < writes.length; i += 20) {
    const batch = writes.slice(i, i + 20);
    await setMany(batch.map(({ path, document }) => ({ path, document })));
    const done = syncOf(user.id);
    for (const w of batch) {
      if (w.path === base) profileSent = w.document.data;
      else done.at[w.path] = w.at!;
    }
    saveSync(done);
  }
}

function schedulePush() {
  window.clearTimeout(timer);
  timer = window.setTimeout(() => void push().catch(() => { /* offline: the device copy has it, the next change retries */ }), 800);
}

/** Writes now (before leaving the page for sign-in, for example). */
export async function flush() {
  window.clearTimeout(timer);
  await push();
}

/** Signs in (guest or account), merges the device copy with the account copy, and starts syncing. */
export function startSync(): Promise<Me> {
  ready ??= (async () => {
    const m = await me();
    let pending: { guest: string; at: number } | null = null;
    try { pending = JSON.parse(localStorage.getItem(UPGRADE) || "null"); } catch { /* ignore */ }
    user = m;
    onLocalChange(schedulePush);
    // The log on this device is never thrown away: it merges into whichever account is signed in.
    // (Signing out clears the device on purpose.)
    await pull(m.id);
    await push().catch(() => null);
    if (pending) {
      localStorage.removeItem(UPGRADE);
      if (!m.guest && m.id !== pending.guest && Date.now() - pending.at < 3600_000) upgraded = true;
    }
    readyUser = m;
    for (const l of readyListeners) l();
    return m;
  })().catch((err) => { ready = null; throw err; });
  return ready;
}

/** Reads what changed on the account (another device may have logged a workout), at most every 30 seconds.
 *  If the session could not start (first opened with no signal), it tries again. */
export async function refresh() {
  if (!user) { await startSync(); return; }
  if (Date.now() - pulledAt < 30_000) return;
  await pull(user.id);
  await push();
}

/** True once, right after a guest's log moved into their account. */
export function takeUpgraded(): boolean {
  const u = upgraded;
  upgraded = false;
  return u;
}

/** "Keep my log on all devices": saves, then goes to the hosted email sign-in. */
export async function keepOnAllDevices() {
  const m = await startSync();
  if (!m.guest) return;
  await flush().catch(() => null);
  localStorage.setItem(UPGRADE, JSON.stringify({ guest: m.id, at: Date.now() }));
  await signIn();
}

/** The signed-in person once syncing has started (null before, or offline). */
export function useUser(): Me | null {
  return useSyncExternalStore((cb) => { readyListeners.add(cb); return () => readyListeners.delete(cb); }, () => readyUser);
}

Change history

Point OpenApps store links to openapps.free
f9f4e37d69 · 17 hours ago
Add favicon.ico, local share image and openapi.json for links and guides
2796c0063c · 17 hours ago
Bind agent address in policy constants
cf80a3ca66 · 19 hours ago
Add OpenApp contribution strip and OpenApps source links
5e4cc2b9f3 · 19 hours ago
Bounded source sync
ca4a3d3631 · 1 day ago
Bounded source sync
7351063f4d · 1 day ago
Bounded source sync
0ce3238c46 · 1 day ago