Shared UI kit

database.js 120 lines

Files · 11
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120// Shared persistence for the three public-format apps. No browser storage fallback.
export function validate(schema, data) {
  if (!data || typeof data !== "object" || Array.isArray(data))
    throw Error("Expected an object.");
  for (const key of schema.required || [])
    if (data[key] === undefined) throw Error(`${key} is required.`);
  for (const [key, value] of Object.entries(data)) {
    const field = schema.properties[key];
    if (!field) {
      if (schema.additionalProperties === false)
        throw Error(`Unknown field: ${key}.`);
      continue;
    }
    const types = [].concat(field.type);
    if (
      !types.some((type) =>
        type === "null"
          ? value === null
          : type === "integer"
            ? Number.isInteger(value)
            : type === "number"
              ? typeof value === "number" && Number.isFinite(value)
              : typeof value === type,
      )
    )
      throw Error(`${key} must be ${types.join(" or ")}.`);
    if (field.enum && !field.enum.includes(value))
      throw Error(`Invalid ${key}.`);
    if ("const" in field && value !== field.const)
      throw Error(`Invalid ${key}.`);
    if (field.minimum !== undefined && value < field.minimum)
      throw Error(`${key} must be at least ${field.minimum}.`);
    if (field.exclusiveMinimum !== undefined && value <= field.exclusiveMinimum)
      throw Error(`${key} must be greater than ${field.exclusiveMinimum}.`);
    if (
      value !== null &&
      field.pattern &&
      !new RegExp(field.pattern).test(value)
    )
      throw Error(`Invalid ${key} format.`);
    if (
      typeof value === "string" &&
      field.pattern === "^\\d{4}-\\d{2}-\\d{2}$" &&
      (Number.isNaN(Date.parse(value)) ||
        new Date(value).toISOString().slice(0, 10) !== value)
    )
      throw Error(`Invalid ${key} date.`);
  }
  return data;
}
export function database(L, ddl, manifest) {
  let tail = Promise.resolve();
  const ready = (async () => {
    for (const sql of ddl
      .split(";")
      .map((s) => s.trim())
      .filter(Boolean)) {
      const table = /CREATE TABLE (\w+)/.exec(sql)[1];
      if (
        !(
          await L.db.query(
            "SELECT name FROM sqlite_master WHERE type = ? AND name = ?",
            ["table", table],
          )
        ).length
      )
        await L.db.exec(sql);
    }
  })();
  const run = (operation) => {
    const result = tail.then(() => ready).then(operation);
    tail = result.catch(() => {});
    return result;
  };
  const get = async (table, id) =>
    (await L.db.query(`SELECT * FROM ${table} WHERE id = ?`, [id]))[0];
  const insert = async (table, data) => {
    const entries = Object.entries(data).filter(([, v]) => v !== undefined);
    await L.db.exec(
      `INSERT INTO ${table} (${entries.map(([k]) => k).join(",")}) VALUES (${entries.map(() => "?").join(",")})`,
      entries.map(([, v]) => v),
    );
    return (
      await L.db.query(
        `SELECT * FROM ${table} WHERE rowid = last_insert_rowid()`,
      )
    )[0];
  };
  const update = async (table, id, data) => {
    const entries = Object.entries(data).filter(
      ([k, v]) => k !== "id" && v !== undefined,
    );
    if (entries.length)
      await L.db.exec(
        `UPDATE ${table} SET ${entries.map(([k]) => `${k} = ?`).join(",")} WHERE id = ?`,
        [...entries.map(([, v]) => v), id],
      );
    return get(table, id);
  };
  const emit = async (name, row) => {
    const schema = manifest.requested_permissions.events[name].schema;
    const payload = Object.fromEntries(
      Object.entries(row).filter(([k]) => k in schema.properties),
    );
    validate(schema, payload);
    await L.events.emit(name, payload);
  };
  return {
    ready,
    run,
    get,
    insert,
    update,
    emit,
    query: (...args) => L.db.query(...args),
    exec: (...args) => L.db.exec(...args),
    check: (name, data) =>
      validate(manifest.requested_permissions.listeners[name].schema, data),
  };
}