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;
},
};
}