Shared UI kit
record-store.js 246 lines
Files · 11
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
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);
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];
}
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.",
);
await dismissSamples();
for (const sample of samples) await insert(sample, { sample: true });
}),
importItems: (items) =>
enqueue(async () => {
let addedCount = 0,
updatedCount = 0;
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 };
}),
};
L.events.listen(listenerName, (data) =>
store.add(data).catch((error) => {
notify({ error: `Incoming record: ${String(error.message || error)}` });
throw error;
}),
);
return store;
}