Shared UI kit

record-store.js 246 lines

Files · 11
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246// The manifest is the validation contract for both incoming data and emitted events.
export function validate(schema, value, path = "Record") {
  if (schema.type) {
    const types = [].concat(schema.type);
    const valid = types.some((type) =>
      type === "null"
        ? value === null
        : type === "integer"
          ? Number.isSafeInteger(value)
          : type === "number"
            ? typeof value === "number" && Number.isFinite(value)
            : type === "object"
              ? value !== null &&
                typeof value === "object" &&
                !Array.isArray(value)
              : typeof value === type,
    );
    if (!valid) throw new Error(`${path}: expected ${types.join(" or ")}.`);
  }
  if (schema.const !== undefined && value !== schema.const)
    throw new Error(`${path}: unexpected value.`);
  if (schema.enum && !schema.enum.includes(value))
    throw new Error(`${path}: choose ${schema.enum.join(", ")}.`);
  if (
    typeof value === "number" &&
    ((schema.minimum !== undefined && value < schema.minimum) ||
      (schema.maximum !== undefined && value > schema.maximum))
  )
    throw new Error(`${path}: value outside the allowed range.`);
  if (
    typeof value === "string" &&
    schema.pattern &&
    !new RegExp(schema.pattern).test(value)
  )
    throw new Error(`${path}: invalid format.`);
  if (value && typeof value === "object" && !Array.isArray(value)) {
    for (const key of schema.required || [])
      if (!Object.hasOwn(value, key))
        throw new Error(`${path}: ${key} is required.`);
    for (const [key, field] of Object.entries(value)) {
      if (schema.properties?.[key])
        validate(schema.properties[key], field, key.replaceAll("_", " "));
      else if (schema.additionalProperties === false)
        throw new Error(`${path}: unknown field ${key}.`);
    }
  }
  if (
    schema.anyOf &&
    !schema.anyOf.some((branch) => {
      try {
        validate(branch, value, path);
        return true;
      } catch {
        return false;
      }
    })
  )
    throw new Error(`${path}: supply a title or synopsis.`);
}

export function createRecordStore({
  manifest,
  ddl,
  table,
  key = "id",
  added,
  updated,
  deleted,
  afterSave,
  readQuery,
  eventData = async (row) => row,
  sameItem,
}) {
  const L = window.lattice;
  const listeners = new Set();
  let state = {
    rows: [],
    ready: false,
    error: "",
    warning: "",
    offerSamples: false,
  };
  let queue = Promise.resolve();
  const listenerName = Object.keys(manifest.requested_permissions.listeners)[0];
  const inputSchema =
    manifest.requested_permissions.listeners[listenerName].schema;
  const preference = "record-apps:sample-choice";
  const notify = (patch) => {
    state = { ...state, ...patch };
    listeners.forEach((fn) => fn());
  };
  const readRows = () =>
    L.db.query(readQuery || `SELECT * FROM ${table} ORDER BY ${key} DESC`);
  const ready = (async () => {
    for (const sql of ddl.split(/;\s*(?:\n|$)/).map(s => s.trim()).filter(Boolean))
      await L.db.exec(sql.replace(/^CREATE TABLE\s+/i, "CREATE TABLE IF NOT EXISTS "));
    const choice = await L.db.query("SELECT value FROM kv WHERE key = ?", [
      preference,
    ]);
    const rows = await readRows();
    notify({ rows, ready: true, offerSamples: !rows.length && !choice.length });
  })();
  ready.catch((error) => notify({ error: String(error.message || error) }));
  const enqueue = (operation) => {
    const result = queue.then(() => ready).then(operation);
    queue = result.catch(() => {});
    return result;
  };
  async function dismissSamples() {
    await L.db.exec("INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)", [
      preference,
      "true",
    ]);
    notify({ offerSamples: false });
  }
  async function emit(name, row) {
    if (!name) return;
    row = await eventData(row);
    const schema = manifest.requested_permissions.events[name].schema;
    const payload = Object.fromEntries(
      Object.keys(schema.properties)
        .filter((k) => Object.hasOwn(row, k))
        .map((k) => [k, row[k]]),
    );
    validate(schema, payload);
    // A failed downstream delivery must not invite the user to insert the record twice.
    try {
      await L.events.emit(name, payload);
    } catch (error) {
      notify({
        warning: `Saved. Could not update connected apps: ${String(error.message || error)}`,
      });
    }
  }
  async function insert(data, { sample = false } = {}) {
    validate(inputSchema, data);
    if (table === "books") {
      const found = await L.db.query("SELECT * FROM books WHERE book_id = ?", [
        data.book_id,
      ]);
      if (found.length) return found[0]; // add-book only adds a previously absent book.
    }
    const keys = Object.keys(data);
    const [row] = await L.db.query(
      `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${keys.map(() => "?").join(", ")}) RETURNING *`,
      keys.map((k) => data[k]),
    );
    if (!sample) await dismissSamples();
    notify({ rows: await readRows(), error: "" });
    await emit(added, row);
    return row;
  }
  async function update(id, data) {
    validate(inputSchema, data);
    const [previous] = await L.db.query(
      `SELECT * FROM ${table} WHERE ${key} = ?`,
      [id],
    );
    if (!previous) throw new Error("This record has been deleted.");
    if (data[key] !== undefined && data[key] !== id)
      throw new Error("The record identifier cannot be changed.");
    const keys = Object.keys(data).filter((k) => k !== key);
    if (!keys.length) return previous;
    const [row] = await L.db.query(
      `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(", ")} WHERE ${key} = ? RETURNING *`,
      [...keys.map((k) => data[k]), id],
    );
    notify({ rows: await readRows(), error: "" });
    await emit(updated, row);
    if (afterSave) await afterSave(previous, row, emit);
    return row;
  }
  const store = {
    manifest,
    inputSchema,
    key,
    ready,
    subscribe: (fn) => {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
    getSnapshot: () => state,
    add: (data) => enqueue(() => insert(data)),
    update: (id, data) => enqueue(() => update(id, data)),
    remove: (id) =>
      enqueue(async () => {
        const [row] = await L.db.query(
          `DELETE FROM ${table} WHERE ${key} = ? RETURNING *`,
          [id],
        );
        if (!row) return;
        notify({ rows: await readRows(), error: "" });
        await emit(deleted, row);
      }),
    emit: (name, row) => enqueue(() => emit(name, row)),
    dismissSamples: () => enqueue(dismissSamples),
    seed: (samples) =>
      enqueue(async () => {
        if (!state.offerSamples || state.rows.length)
          throw new Error(
            "Sample data is only available for an empty library on first use.",
          );
        // Persist the choice before inserting; a later launch never silently seeds.
        await dismissSamples();
        for (const sample of samples) await insert(sample, { sample: true });
      }),
    importItems: (items) =>
      enqueue(async () => {
        let addedCount = 0,
          updatedCount = 0;
        // Validate the whole document before changing any rows.
        items.forEach((item) => validate(inputSchema, item));
        for (const item of items) {
          const rows = await readRows();
          const previous = rows.find((row) =>
            sameItem ? sameItem(item, row) : item.guid
              ? row.guid === item.guid &&
                row.source_url === (item.source_url ?? null)
              : item.link
                ? row.link === item.link &&
                  row.source_url === (item.source_url ?? null)
                : false,
          );
          if (previous) {
            if (Object.keys(item).some((k) => item[k] !== previous[k])) {
              await update(previous[key], item);
              updatedCount++;
            }
          } else {
            await insert(item);
            addedCount++;
          }
        }
        notify({ rows: await readRows(), error: "" });
        return { added: addedCount, updated: updatedCount };
      }),
  };
  // Register immediately; queued delivery waits for the exact DDL to finish.
  L.events.listen(listenerName, (data) =>
    store.add(data).catch((error) => {
      notify({ error: `Incoming record: ${String(error.message || error)}` });
      throw error; // Let Lattice record the listener failure instead of acknowledging success.
    }),
  );
  return store;
}