Habits

runtime.js 139 lines

Files · 10
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139import Ajv from "ajv";
export function localDate(now = new Date()) {
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
export function validDate(value) {
  if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
    throw new Error("Use a valid date in YYYY-MM-DD format.");
  const d = new Date(value + "T12:00:00Z");
  if (!Number.isFinite(+d) || d.toISOString().slice(0, 10) !== value)
    throw new Error("That calendar date does not exist.");
  return value;
}
export function addDays(day, n) {
  const d = new Date(validDate(day) + "T12:00:00Z");
  d.setUTCDate(d.getUTCDate() + n);
  return d.toISOString().slice(0, 10);
}
export function weekday(day) {
  return new Date(validDate(day) + "T12:00:00Z").getUTCDay();
}
export function monday(day) {
  return addDays(day, -((weekday(day) + 6) % 7));
}
export function daysBetween(a, b) {
  return Math.round(
    (new Date(b + "T12:00:00Z") - new Date(a + "T12:00:00Z")) / 86400000,
  );
}
export const nowISO = () => new Date().toISOString();
export const uid = () => crypto.randomUUID();
export function nonblank(value, name) {
  if (typeof value !== "string" || !value.trim())
    throw new Error(`${name} is required.`);
  return value.trim();
}
export function runtime(L, manifest, DDL) {
  const ajv = new Ajv({ allErrors: true, strict: false });
  const validators = Object.fromEntries(
    ["events", "listeners"].map((kind) => [
      kind,
      Object.fromEntries(
        Object.entries(manifest.requested_permissions[kind]).map(
          ([name, spec]) => [name, ajv.compile(spec.schema)],
        ),
      ),
    ]),
  );
  const validate = (kind, name, data) => {
    const test = validators[kind][name];
    if (!test || !test(data))
      throw new Error(
        `Invalid ${name} payload: ${ajv.errorsText(test?.errors)}`,
      );
  };
  let tail = Promise.resolve();
  const subscribers = new Set();
  let deliveryError = "";
  const ready = (async () => {
    for (const sql of DDL.replace(/--[^\n]*/g, "")
      .replace(/\/\*[\s\S]*?\*\//g, "")
      .split(";")
      .map((x) => x.trim())
      .filter(Boolean))
      await L.db.exec(sql);
  })();
  const changed = () => subscribers.forEach((fn) => fn());
  const run = (fn) => {
    const task = tail.then(() => ready).then(fn);
    tail = task.catch(() => {});
    return task.finally(changed);
  };
  async function publish(name, payload, key = uid()) {
    validate("events", name, payload);
    await L.db.exec(
      "INSERT OR IGNORE INTO event_outbox (id,name,payload,created_at) VALUES (?,?,?,?)",
      [key, name, JSON.stringify(payload), nowISO()],
    );
  }
  async function flush() {
    deliveryError = "";
    for (const row of await L.db.query(
      "SELECT * FROM event_outbox WHERE delivered_at IS NULL ORDER BY created_at,id",
    )) {
      try {
        await L.events.emit(row.name, JSON.parse(row.payload));
        await L.db.exec("UPDATE event_outbox SET delivered_at=? WHERE id=?", [
          nowISO(),
          row.id,
        ]);
      } catch (err) {
        deliveryError =
          "Changes saved. Event delivery is pending; it will retry while the app is open.";
        break;
      }
    }
  }
  function listen(name, handler) {
    L.events.listen(name, (data, meta) =>
      run(async () => {
        validate("listeners", name, data);
        const key = meta?.hash ? `${name}:${meta.hash}` : null;
        if (
          key &&
          (
            await L.db.query(
              "SELECT event_hash FROM listener_receipts WHERE event_hash=?",
              [key],
            )
          ).length
        ) {
          await flush();
          return;
        }
        await handler(data);
        if (key)
          await L.db.exec(
            "INSERT OR IGNORE INTO listener_receipts VALUES (?,?)",
            [key, nowISO()],
          );
        await flush();
      }),
    );
  }
  return {
    ready,
    run,
    publish,
    flush,
    listen,
    validate,
    subscribe(fn) {
      subscribers.add(fn);
      return () => subscribers.delete(fn);
    },
    get deliveryError() {
      return deliveryError;
    },
  };
}