feat: initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
/tmp
|
||||||
|
/dist
|
||||||
Generated
+5637
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "abode",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A management interface for household stuff",
|
||||||
|
"author": "Codinget <codinget@codi.moe>",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"abode-migrate": "dist/bin/abode-migrate.cjs",
|
||||||
|
"abode-repl": "dist/bin/abode-repl.cjs",
|
||||||
|
"abode-web": "dist/bin/abode-web.cjs",
|
||||||
|
"abode-tui": "dist/bin/abode-tui.cjs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"repl": "tsx --import ./src/meta/dev/register.ts",
|
||||||
|
"abode-migrate": "tsx --import ./src/meta/dev/register.ts src/bin/abode-migrate.ts",
|
||||||
|
"abode-repl": "tsx --import ./src/meta/dev/register.ts src/bin/abode-repl.ts",
|
||||||
|
"abode-web": "tsx --import ./src/meta/dev/register.ts --import ./src/meta/dev/webhot.ts src/bin/abode-web.ts",
|
||||||
|
"abode-tui": "tsx --import ./src/meta/dev/register.ts --import ./src/meta/dev/silenthot.ts src/bin/abode-tui.ts",
|
||||||
|
"abode-sources": "tsx --import ./src/meta/dev/register.ts src/bin/abode-sources.ts",
|
||||||
|
"build": "NODE_ENV=production npm run build:impl",
|
||||||
|
"build:impl": "rm -rf dist && tsx node_modules/.bin/webpack && chmod +x dist/bin/* && chmod -x dist/bin/*.*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@koa/bodyparser": "^6.0.0",
|
||||||
|
"@koa/router": "^14.0.0",
|
||||||
|
"@reduxjs/toolkit": "^2.9.0",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"fullscreen-ink": "^0.1.0",
|
||||||
|
"hash-wasm": "^4.12.0",
|
||||||
|
"ink": "^6.3.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"koa": "^3.0.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-redux": "^9.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/koa": "^3.0.0",
|
||||||
|
"@types/koa__router": "^12.0.4",
|
||||||
|
"@types/react": "^19.1.12",
|
||||||
|
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||||
|
"copy-webpack-plugin": "^13.0.1",
|
||||||
|
"css-loader": "^7.1.2",
|
||||||
|
"dynohot": "^2.1.1",
|
||||||
|
"mini-css-extract-plugin": "^2.9.4",
|
||||||
|
"raw-loader": "^4.0.2",
|
||||||
|
"scss-loader": "^0.0.1",
|
||||||
|
"ts-loader": "^9.5.4",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"val-loader": "^6.0.0",
|
||||||
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
|
"webpack-cli": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"better-sqlite3": "^12.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { getMigrator, getDbInterface } from "../db/index.js";
|
||||||
|
import { hashPassword } from "../util/hash.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function printUsage(err: boolean | string = false): never {
|
||||||
|
const log = (err ? console.error : console.log).bind(console);
|
||||||
|
if (typeof err === "string") {
|
||||||
|
log(`Error: ${err}`);
|
||||||
|
log("");
|
||||||
|
}
|
||||||
|
log("Usage:");
|
||||||
|
log("\tabode-migrate --help");
|
||||||
|
log("\tabode-migrate <database> current");
|
||||||
|
log("\tabode-migrate <database> migrate");
|
||||||
|
log("\tabode-migrate <database> available");
|
||||||
|
log("\tabode-migrate <database> init");
|
||||||
|
process.exit(err ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.length || ["-h", "--help", "help"].some((x) => args.includes(x))) {
|
||||||
|
printUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = args[0];
|
||||||
|
const cmd = args[1];
|
||||||
|
|
||||||
|
if (!url) printUsage("missing <database>");
|
||||||
|
if (!cmd) printUsage("missing command");
|
||||||
|
if (!["current", "migrate", "available", "init"].includes(cmd))
|
||||||
|
printUsage(`invalid command: ${cmd}`);
|
||||||
|
if (args.length > 2) printUsage("too many arguments");
|
||||||
|
|
||||||
|
const migrator = await getMigrator(url);
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case "current": {
|
||||||
|
const current = await migrator.listAppliedMigrations();
|
||||||
|
console.log("Applied migrations:");
|
||||||
|
if (!current.length) console.log("(none)");
|
||||||
|
for (const migration of current) {
|
||||||
|
console.log(
|
||||||
|
`- ${migration.id} (${migration.name}) applied at ${migration.applied_at}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "migrate": {
|
||||||
|
const latest = migrator.listAvailableMigrations().at(-1);
|
||||||
|
if (!latest) throw new Error("No available migration");
|
||||||
|
await migrator.migrateTo(latest.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "available": {
|
||||||
|
const available = migrator.listAvailableMigrations();
|
||||||
|
console.log("Available migrations:");
|
||||||
|
if (!available.length) console.log("(none)");
|
||||||
|
for (const migration of available) {
|
||||||
|
console.log(`- ${migration.id} (${migration.name})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "init": {
|
||||||
|
const db = await getDbInterface(url);
|
||||||
|
let users = await db.listUsers();
|
||||||
|
if (!users.some((x) => x.flags.admin)) {
|
||||||
|
const { uid } = await db.createUser({
|
||||||
|
email: "admin@codi.moe",
|
||||||
|
name: "Admin",
|
||||||
|
flags: { admin: true },
|
||||||
|
password: await hashPassword("changeme"),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Created user 'admin@codi.moe' (${uid}) with password 'changeme' and admin flag`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
users = await db.listUsers();
|
||||||
|
const admin = users.find((x) => x.flags.admin && x.name === "Admin");
|
||||||
|
if (admin) {
|
||||||
|
const tokens = await db.listApikeysByUser(admin.uid);
|
||||||
|
if (!tokens.some((x) => x.permissions.admin && x.permissions.all)) {
|
||||||
|
const [apikey, token] = await db.createApikey({
|
||||||
|
uid: admin.uid,
|
||||||
|
name: "admin",
|
||||||
|
permissions: { admin: true, all: true },
|
||||||
|
expires_at: null,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Created apikey '${token}' (${apikey.kid}) with permissions admin, all and no expiry`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { getDbInterface, getMigrator } from "../db/index.js";
|
||||||
|
import { start } from "node:repl";
|
||||||
|
import * as errors from "../db/types/errors.js";
|
||||||
|
import * as validators from "../schema/validators.js";
|
||||||
|
import { hashPassword, validatePassword } from "../util/hash.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function printUsage(err: boolean | string = false): never {
|
||||||
|
const log = (err ? console.error : console.log).bind(console);
|
||||||
|
if (typeof err === "string") {
|
||||||
|
log(`Error: ${err}`);
|
||||||
|
log("");
|
||||||
|
}
|
||||||
|
log("Usage:");
|
||||||
|
log("\tabode-repl --help");
|
||||||
|
log("\tabode-repl [database]");
|
||||||
|
process.exit(err ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["-h", "--help", "help"].some((x) => args.includes(x))) {
|
||||||
|
printUsage();
|
||||||
|
}
|
||||||
|
if (args.length > 1) printUsage("too many arguments");
|
||||||
|
|
||||||
|
const inject = Object.assign({}, errors, {
|
||||||
|
getDbInterface,
|
||||||
|
getMigrator,
|
||||||
|
validators,
|
||||||
|
errors,
|
||||||
|
hashPassword,
|
||||||
|
validatePassword,
|
||||||
|
});
|
||||||
|
Object.assign(inject, { abode: inject });
|
||||||
|
Object.assign(globalThis, inject);
|
||||||
|
|
||||||
|
const url: string | undefined = args[0];
|
||||||
|
if (url) {
|
||||||
|
let someSuccess = false;
|
||||||
|
try {
|
||||||
|
const db = await getDbInterface(url);
|
||||||
|
Object.assign(globalThis, { db });
|
||||||
|
console.log("`db` set to a DbInterface");
|
||||||
|
someSuccess = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to obtain DbInterface", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const migrator = await getMigrator(url);
|
||||||
|
Object.assign(globalThis, { migrator });
|
||||||
|
console.log("`migrator` set to a Migrator");
|
||||||
|
someSuccess = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to obtain Migrator", e);
|
||||||
|
}
|
||||||
|
if (!someSuccess) throw new Error(`Failed to obtain anything for url ${url}`);
|
||||||
|
} else {
|
||||||
|
console.log("Pass <database> to inject dbInterface/migrator");
|
||||||
|
}
|
||||||
|
|
||||||
|
start({ useGlobal: true });
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { getDbSources } from "../db/dbSources.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function printUsage(err: boolean | string = false): never {
|
||||||
|
const log = (err ? console.error : console.log).bind(console);
|
||||||
|
if (typeof err === "string") {
|
||||||
|
log(`Error: ${err}`);
|
||||||
|
log("");
|
||||||
|
}
|
||||||
|
log("Usage:");
|
||||||
|
log("\tabode-sources --help");
|
||||||
|
log("\tabode-sources [database]");
|
||||||
|
process.exit(err ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["-h", "--help", "help"].some((x) => args.includes(x))) {
|
||||||
|
printUsage();
|
||||||
|
}
|
||||||
|
if (args.length > 1) printUsage("too many arguments");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Compiled with ${compiledSources.length} sources:`,
|
||||||
|
compiledSources.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = args[0] ?? "abode://";
|
||||||
|
const sources = await getDbSources(url);
|
||||||
|
console.log(`Found ${sources.length} sources for url ${url}`);
|
||||||
|
for (const source of sources) {
|
||||||
|
console.log(`- ${source.name}`);
|
||||||
|
console.log(
|
||||||
|
" - protocols:",
|
||||||
|
source.protocols.map((x) => `'${x}'`).join(" ")
|
||||||
|
);
|
||||||
|
const match = source.checkUrl(url);
|
||||||
|
console.log(` - matches url: ${match}`);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const db = await source.getDbInterface(url);
|
||||||
|
console.log(
|
||||||
|
` - generates an interface named ${db.name} ${
|
||||||
|
db.backend ? "with" : "without"
|
||||||
|
} backend`
|
||||||
|
);
|
||||||
|
await db.close().catch(console.error);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
console.log(" - fails to generate an interface");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const db = await source.getMigrator(url);
|
||||||
|
console.log(
|
||||||
|
` - generates a migrator knowing ${
|
||||||
|
db.listAvailableMigrations().length
|
||||||
|
} migrations`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
console.log(" - fails to generate a migrator");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { withFullScreen } from "fullscreen-ink";
|
||||||
|
import { app } from "../tui/App.js";
|
||||||
|
import { getDbInterface } from "../db/index.js";
|
||||||
|
import type {} from "dynohot";
|
||||||
|
import { createStore } from "../react/store/store.js";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function printUsage(err: boolean | string = false): never {
|
||||||
|
const log = (err ? console.error : console.log).bind(console);
|
||||||
|
if (typeof err === "string") {
|
||||||
|
log(`Error: ${err}`);
|
||||||
|
log("");
|
||||||
|
}
|
||||||
|
log("Usage:");
|
||||||
|
log("\tabode-tui --help");
|
||||||
|
log("\tabode-tui <database>");
|
||||||
|
process.exit(err ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (["-h", "--help", "help"].some((x) => args.includes(x))) {
|
||||||
|
printUsage();
|
||||||
|
}
|
||||||
|
if (!args.length) printUsage("missing database argument");
|
||||||
|
if (args.length > 1) printUsage("too many arguments");
|
||||||
|
|
||||||
|
const db = await getDbInterface(args[0]);
|
||||||
|
|
||||||
|
const raw = process.stdin.isRaw;
|
||||||
|
const bgColor = await new Promise<string>((ok, ko) => {
|
||||||
|
process.stdin.setRawMode(true);
|
||||||
|
process.stdin.once("data", (chunk) => {
|
||||||
|
const result = chunk.toString("utf8");
|
||||||
|
const match = result.match(
|
||||||
|
/^\u001b]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)$/
|
||||||
|
);
|
||||||
|
if (!match) return ko("Didn't recognize terminal bg color");
|
||||||
|
const [r, g, b] = match
|
||||||
|
.slice(1, 4)
|
||||||
|
.map((x) => (x.length < 2 ? x.repeat(2) : x.slice(0, 2)))
|
||||||
|
.map((x) => x.toLowerCase());
|
||||||
|
for (const x of [r, g, b])
|
||||||
|
if (x.length !== 2)
|
||||||
|
return ko("Invalid color component in terminal bg color");
|
||||||
|
process.stdin.unref();
|
||||||
|
return ok(["#", r, g, b].join(""));
|
||||||
|
});
|
||||||
|
process.stdout.write("\x1b]11;?\x1b\\");
|
||||||
|
setTimeout(() => {
|
||||||
|
ko(new Error("Giving up finding terminal bg color"));
|
||||||
|
}, 1000);
|
||||||
|
}).finally(() => process.stdin.setRawMode(raw));
|
||||||
|
|
||||||
|
process.removeAllListeners("warning");
|
||||||
|
process.removeAllListeners("multipleResolves");
|
||||||
|
process.removeAllListeners("rejectionHandled");
|
||||||
|
|
||||||
|
const doNothing = () => {};
|
||||||
|
for (const method of [
|
||||||
|
"log",
|
||||||
|
"warn",
|
||||||
|
"error",
|
||||||
|
"dir",
|
||||||
|
"dirxml",
|
||||||
|
"clear",
|
||||||
|
"count",
|
||||||
|
"debug",
|
||||||
|
"info",
|
||||||
|
] as const) {
|
||||||
|
console[method] = doNothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = createStore();
|
||||||
|
const fullscreenApp = withFullScreen(app({ db, bgColor, store }), {
|
||||||
|
exitOnCtrlC: true,
|
||||||
|
});
|
||||||
|
await fullscreenApp.start();
|
||||||
|
if (import.meta.hot) {
|
||||||
|
await import("../meta/dev/restart.js");
|
||||||
|
|
||||||
|
import.meta.hot.accept("../tui/App.js", (mod) => {
|
||||||
|
fullscreenApp.instance.rerender(
|
||||||
|
(mod.app as typeof app)({ db, bgColor, store })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await fullscreenApp.waitUntilExit();
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Koa, { type Middleware } from "koa";
|
||||||
|
import KoaRouter from "@koa/router";
|
||||||
|
import { getDbInterface } from "../db/index.js";
|
||||||
|
import { logRequests } from "../webapi/middleware/logRequests.js";
|
||||||
|
import { isBackendInterface } from "../db/types/DbInterface.js";
|
||||||
|
import { apirouter } from "../webapi/apirouter.js";
|
||||||
|
import { schemarouter } from "../webapi/schemarouter.js";
|
||||||
|
import type {} from "dynohot";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function printUsage(err: boolean | string = false): never {
|
||||||
|
const log = (err ? console.error : console.log).bind(console);
|
||||||
|
if (typeof err === "string") {
|
||||||
|
log(`Error: ${err}`);
|
||||||
|
log("");
|
||||||
|
}
|
||||||
|
log("Usage:");
|
||||||
|
log("\tabode-migrate --help");
|
||||||
|
log("\tabode-migrate <database>");
|
||||||
|
process.exit(err ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["-h", "--help", "help"].some((x) => args.includes(x))) {
|
||||||
|
printUsage();
|
||||||
|
}
|
||||||
|
if (args.length > 1) printUsage("too many arguments");
|
||||||
|
if (args.length < 1) printUsage("missing <database> argument");
|
||||||
|
|
||||||
|
const app = new Koa();
|
||||||
|
|
||||||
|
const db = await getDbInterface(args[0]);
|
||||||
|
if (!isBackendInterface(db)) {
|
||||||
|
throw new Error(`Interface ${db.name} is not a backend interface`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
await import("../meta/dev/restart.js");
|
||||||
|
|
||||||
|
let middleware: Middleware = async (ctx, next) => {
|
||||||
|
ctx.status = 500;
|
||||||
|
ctx.body = { ok: false, err: "still_loading" };
|
||||||
|
void next;
|
||||||
|
};
|
||||||
|
app.use((ctx, next) => middleware(ctx, next));
|
||||||
|
|
||||||
|
let currentDb = db;
|
||||||
|
let makeApiRouter = apirouter;
|
||||||
|
|
||||||
|
const listenRouter = () => {
|
||||||
|
console.log("[hot] Creating router");
|
||||||
|
const router = new KoaRouter();
|
||||||
|
router.use(logRequests);
|
||||||
|
|
||||||
|
const api = makeApiRouter(currentDb);
|
||||||
|
router.use("/api", api.middleware(), api.allowedMethods());
|
||||||
|
|
||||||
|
const schema = schemarouter();
|
||||||
|
router.use("/schema", schema.middleware(), schema.allowedMethods());
|
||||||
|
|
||||||
|
const rm = router.middleware();
|
||||||
|
const ram = router.allowedMethods();
|
||||||
|
middleware = async (ctx, next) =>
|
||||||
|
rm(ctx as any, () => ram(ctx as any, next));
|
||||||
|
};
|
||||||
|
listenRouter();
|
||||||
|
|
||||||
|
import.meta.hot.accept("../webapi/apirouter.js", (mod) => {
|
||||||
|
console.log("[hot] Reloading apirouter");
|
||||||
|
makeApiRouter = mod.apirouter as typeof apirouter;
|
||||||
|
listenRouter();
|
||||||
|
});
|
||||||
|
import.meta.hot.accept("../db/index.js", async (mod) => {
|
||||||
|
console.log("[hot] Reloading DbInterface");
|
||||||
|
const db = await (mod.getDbInterface as typeof getDbInterface)(args[0]);
|
||||||
|
if (!isBackendInterface(db)) {
|
||||||
|
throw new Error(`Interface ${db.name} is not a backend interface`);
|
||||||
|
}
|
||||||
|
currentDb = db;
|
||||||
|
listenRouter();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const router = new KoaRouter();
|
||||||
|
router.use(logRequests);
|
||||||
|
app.use(router.middleware());
|
||||||
|
app.use(router.allowedMethods());
|
||||||
|
|
||||||
|
const api = apirouter(db);
|
||||||
|
router.use("/api", api.middleware(), api.allowedMethods());
|
||||||
|
|
||||||
|
const schema = schemarouter();
|
||||||
|
router.use("/schema", schema.middleware(), schema.allowedMethods());
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = +(process.env.PORT ?? "3000");
|
||||||
|
if (isNaN(port)) throw new Error(`Invalid port: ${process.env.PORT}`);
|
||||||
|
app.listen(port);
|
||||||
|
console.log(`Listening on port ${port}`);
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
||||||
|
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
||||||
|
import type { DbInterface } from "../types/DbInterface.js";
|
||||||
|
import {
|
||||||
|
ConflictAbodeError,
|
||||||
|
InvalidAbodeError,
|
||||||
|
NotAuthorizedAbodeError,
|
||||||
|
NotFoundAbodeError,
|
||||||
|
ReadonlyAbodeError,
|
||||||
|
} from "../types/errors.js";
|
||||||
|
import type {
|
||||||
|
CreateNote,
|
||||||
|
Note,
|
||||||
|
PartialNote,
|
||||||
|
UpdateNote,
|
||||||
|
} from "../types/Note.js";
|
||||||
|
import type {
|
||||||
|
CreateResident,
|
||||||
|
Resident,
|
||||||
|
updateResident,
|
||||||
|
} from "../types/Resident.js";
|
||||||
|
import type {
|
||||||
|
PartialUser,
|
||||||
|
ClientUser,
|
||||||
|
CreateUser,
|
||||||
|
UpdateUser,
|
||||||
|
} from "../types/User.js";
|
||||||
|
|
||||||
|
export class ApiInterface implements DbInterface {
|
||||||
|
#root: string;
|
||||||
|
#headers: Record<string, string>;
|
||||||
|
#readonly: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
root: string,
|
||||||
|
{
|
||||||
|
headers = {},
|
||||||
|
readonly = false,
|
||||||
|
}: {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
readonly?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
if (root.endsWith("/")) root = root.slice(0, -1);
|
||||||
|
this.#root = root;
|
||||||
|
this.#headers = headers;
|
||||||
|
this.#readonly = readonly;
|
||||||
|
}
|
||||||
|
|
||||||
|
#url(route: string, params?: Record<string, string>): string {
|
||||||
|
if (params) {
|
||||||
|
const remaining = new Map(Object.entries(params));
|
||||||
|
route = route
|
||||||
|
.split("/")
|
||||||
|
.map((part) => {
|
||||||
|
if (part.startsWith(":")) {
|
||||||
|
const value = remaining.get(part.slice(1));
|
||||||
|
remaining.delete(part);
|
||||||
|
if (value === undefined)
|
||||||
|
throw new Error(`Missing ${part} in params`);
|
||||||
|
return encodeURIComponent(value);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
})
|
||||||
|
.join("/");
|
||||||
|
if (remaining.size) {
|
||||||
|
const sp = new URLSearchParams([...remaining.entries()]);
|
||||||
|
route += "?" + sp.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.#root + route;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #call<T>(
|
||||||
|
method: string,
|
||||||
|
route: string,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
}: {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const resolvedHeaders = { ...this.#headers, ...headers };
|
||||||
|
if (body !== undefined) {
|
||||||
|
body = JSON.stringify(body);
|
||||||
|
resolvedHeaders["Content-Type"] = "application/json";
|
||||||
|
resolvedHeaders["Content-Length"] = "" + (body as string).length;
|
||||||
|
}
|
||||||
|
const url = this.#url(route, params);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: resolvedHeaders,
|
||||||
|
body: body as string,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
switch (res.status) {
|
||||||
|
case 400:
|
||||||
|
throw new InvalidAbodeError();
|
||||||
|
case 401:
|
||||||
|
throw new NotAuthorizedAbodeError();
|
||||||
|
case 403:
|
||||||
|
throw new ReadonlyAbodeError();
|
||||||
|
case 404:
|
||||||
|
throw new NotFoundAbodeError();
|
||||||
|
case 409:
|
||||||
|
throw new ConflictAbodeError();
|
||||||
|
default:
|
||||||
|
throw new Error(`${res.status} ${res.statusText} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
#checkReadonly(): void {
|
||||||
|
if (this.#readonly) throw new ReadonlyAbodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
get _() {
|
||||||
|
return {
|
||||||
|
root: this.#root,
|
||||||
|
headers: { ...this.#headers },
|
||||||
|
url: this.#url.bind(this),
|
||||||
|
call: this.#call.bind(this),
|
||||||
|
self: () => this.#call<ClientUser>("GET", "/auth/self"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get readonly(): boolean {
|
||||||
|
return this.#readonly;
|
||||||
|
}
|
||||||
|
get backend(): false {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
get name(): "api" {
|
||||||
|
return "api";
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers(): Promise<(PartialUser | ClientUser)[]> {
|
||||||
|
return this.#call("GET", "/users");
|
||||||
|
}
|
||||||
|
async getUserById(uid: string): Promise<PartialUser | ClientUser> {
|
||||||
|
return this.#call("GET", "/users/:uid", { params: { uid } });
|
||||||
|
}
|
||||||
|
async deleteUserById(uid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
await this.#call("DELETE", "/users/:uid", { params: { uid } });
|
||||||
|
}
|
||||||
|
async createUser(user: CreateUser): Promise<ClientUser> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#call("POST", "/users", { body: user });
|
||||||
|
}
|
||||||
|
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#call("PATCH", "/users/:uid", {
|
||||||
|
params: { uid: user.uid },
|
||||||
|
body: user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByEmail(email: string): Promise<PartialUser | ClientUser> {
|
||||||
|
return this.#call("GET", "/users/by-email", { params: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAbodes(): Promise<Abode[]> {
|
||||||
|
return this.#call("GET", "/abodes");
|
||||||
|
}
|
||||||
|
async getAbodeById(aid: string): Promise<Abode> {
|
||||||
|
return this.#call("GET", "/abodes/:aid", { params: { aid } });
|
||||||
|
}
|
||||||
|
async deleteAbodeById(aid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
await this.#call("DELETE", "/abodes/:aid", { params: { aid } });
|
||||||
|
}
|
||||||
|
async createAbode(abode: CreateAbode): Promise<Abode> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#call("POST", "/abodes", { body: abode });
|
||||||
|
}
|
||||||
|
async updateAbode(abode: UpdateAbode): Promise<Abode> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#call("PATCH", "/abodes/:aid", {
|
||||||
|
params: { aid: abode.aid },
|
||||||
|
body: abode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResidents(): Promise<Resident[]> {
|
||||||
|
return this.#call("GET", "/residents");
|
||||||
|
}
|
||||||
|
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
||||||
|
return this.#call("GET", "/residents/:uid/:aid", { params: { uid, aid } });
|
||||||
|
}
|
||||||
|
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
await this.#call("DELETE", "/residents/:uid/:aid", {
|
||||||
|
params: { uid, aid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async createResident(resident: CreateResident): Promise<Resident> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#call("POST", "/residents", { body: resident });
|
||||||
|
}
|
||||||
|
async updateResident(resident: updateResident): Promise<Resident> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#call("PATCH", "/residents/:uid/:aid", {
|
||||||
|
params: { uid: resident.uid, aid: resident.aid },
|
||||||
|
body: resident,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
||||||
|
return this.#call("GET", "/users/:uid/residents", { params: { uid } });
|
||||||
|
}
|
||||||
|
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
||||||
|
return this.#call("GET", "/abodes/:aid/residents", { params: { aid } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsersByAbodeId(aid: string): Promise<(PartialUser | ClientUser)[]> {
|
||||||
|
return this.#call("GET", "/abodes/:aid/users", { params: { aid } });
|
||||||
|
}
|
||||||
|
async listAbodesByUserId(uid: string): Promise<Abode[]> {
|
||||||
|
return this.#call("GET", "/users/:uid/abodes", { params: { uid } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSessionsByUser(uid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
await this.#call("POST", "/users/:uid/auth/clear-sessions", {
|
||||||
|
params: { uid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
||||||
|
return this.#call("GET", "/users/:uid/apikeys", { params: { uid } });
|
||||||
|
}
|
||||||
|
async getApikeyById(kid: string): Promise<ClientApikey> {
|
||||||
|
return this.#call("GET", "/apikeys/:kid", {
|
||||||
|
params: { kid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async createApikey(
|
||||||
|
apikey: CreateApikey
|
||||||
|
): Promise<[ClientApikey, `at_${string}`]> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { apikey: key, token } = await this.#call<{
|
||||||
|
apikey: ClientApikey;
|
||||||
|
token: `at_${string}`;
|
||||||
|
}>("POST", "/users/:uid/apikeys", {
|
||||||
|
params: { uid: apikey.uid },
|
||||||
|
body: apikey,
|
||||||
|
});
|
||||||
|
return [key, token];
|
||||||
|
}
|
||||||
|
async deleteApikeyById(kid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
await this.#call("DELETE", "/apikeys/:kid", {
|
||||||
|
params: { kid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNotes(): Promise<PartialNote[]> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async getNoteById(nid: string): Promise<Note> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async deleteNoteById(nid: string): Promise<void> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async createNote(note: CreateNote): Promise<Note> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async updateNote(note: UpdateNote): Promise<Note> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { GetDbDynamic } from "../types/GetDb.js";
|
||||||
|
import { apiProtocols } from "./url.js";
|
||||||
|
|
||||||
|
const getApi = () =>
|
||||||
|
import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then(
|
||||||
|
(x) => x.default
|
||||||
|
);
|
||||||
|
|
||||||
|
const getApiDynamic: GetDbDynamic = {
|
||||||
|
name: "api",
|
||||||
|
protocols: apiProtocols,
|
||||||
|
getSource: getApi,
|
||||||
|
};
|
||||||
|
export default getApiDynamic;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { GetDbStatic } from "../types/GetDb.js";
|
||||||
|
import { ApiInterface } from "./ApiInterface.js";
|
||||||
|
import { apiProtocols, isApiUrl, parseApiUrl } from "./url.js";
|
||||||
|
|
||||||
|
const getApiStatic: GetDbStatic = {
|
||||||
|
name: "api",
|
||||||
|
protocols: apiProtocols,
|
||||||
|
checkUrl: isApiUrl,
|
||||||
|
getDbInterface: async (url) => {
|
||||||
|
const [root, { headers, readonly }] = parseApiUrl(url);
|
||||||
|
const db = new ApiInterface(root, { headers, readonly });
|
||||||
|
await db._.self();
|
||||||
|
return db;
|
||||||
|
},
|
||||||
|
getMigrator: async () => {
|
||||||
|
throw new Error("No migrator for api");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default getApiStatic;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const apiProtocols = ["abode+https:", "abode+http:", "https:", "http:"];
|
||||||
|
|
||||||
|
export function isApiUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return apiProtocols.includes(urlObj.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApiUrl(url: string) {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
if (!apiProtocols.includes(urlObj.protocol))
|
||||||
|
throw new Error("Not an {abode+,}http{s,}: protocol");
|
||||||
|
// can't replace just the protocol, apparently
|
||||||
|
urlObj.href = urlObj.href.replace(/^abode\+/, "");
|
||||||
|
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
[...urlObj.searchParams.entries()].filter(([param]) => param !== "readonly")
|
||||||
|
);
|
||||||
|
if (urlObj.username) {
|
||||||
|
headers["Authorization"] =
|
||||||
|
"Basic " +
|
||||||
|
btoa(
|
||||||
|
[
|
||||||
|
decodeURIComponent(urlObj.username),
|
||||||
|
decodeURIComponent(urlObj.password),
|
||||||
|
].join(":")
|
||||||
|
);
|
||||||
|
urlObj.username = "";
|
||||||
|
urlObj.password = "";
|
||||||
|
}
|
||||||
|
urlObj.search = "";
|
||||||
|
return [urlObj.href, { headers, readonly }] as const;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import getApiDynamic from "./api/getdb.dyn.js";
|
||||||
|
import getSqliteDynamic from "./sqlite/getdb.dyn.js";
|
||||||
|
import type { GetDbDynamic, GetDbStatic } from "./types/GetDb.js";
|
||||||
|
|
||||||
|
const dynamicSources: GetDbDynamic[] = [getSqliteDynamic, getApiDynamic];
|
||||||
|
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const dbSources: GetDbStatic[] = [];
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
for (const source of dynamicSources) {
|
||||||
|
if (source.protocols.includes(urlObj.protocol)) {
|
||||||
|
promises.push(
|
||||||
|
source
|
||||||
|
.getSource(url)
|
||||||
|
.catch(() => null)
|
||||||
|
.then((dbSource) => {
|
||||||
|
if (dbSource) dbSources.push(dbSource);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
|
return dbSources;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { GetDbStatic } from "./types/GetDb.js";
|
||||||
|
|
||||||
|
let rawGetDbSources: typeof getDbSources | undefined = undefined;
|
||||||
|
const getGetDbSources = () =>
|
||||||
|
import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then(
|
||||||
|
(x) => x.getDbSources
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||||
|
if (!rawGetDbSources) rawGetDbSources = await getGetDbSources();
|
||||||
|
return rawGetDbSources(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import getApiStatic from "./api/getdb.static.js";
|
||||||
|
import getSqliteStatic from "./sqlite/getdb.static.js";
|
||||||
|
import type { GetDbStatic } from "./types/GetDb.js";
|
||||||
|
|
||||||
|
const dbSources: GetDbStatic[] = [getSqliteStatic, getApiStatic];
|
||||||
|
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||||
|
void url;
|
||||||
|
return dbSources;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dbSources.static.js";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getDbSources } from "./dbSources.js";
|
||||||
|
import type { DbInterface } from "./types/DbInterface.js";
|
||||||
|
import type { Migrator } from "./types/Migrator.js";
|
||||||
|
|
||||||
|
export async function getDbInterface(url: string): Promise<DbInterface> {
|
||||||
|
for (const source of await getDbSources(url)) {
|
||||||
|
if (source.checkUrl(url)) return source.getDbInterface(url);
|
||||||
|
}
|
||||||
|
throw new Error(`No source found for url ${url}`);
|
||||||
|
}
|
||||||
|
export async function getMigrator(url: string): Promise<Migrator> {
|
||||||
|
for (const source of await getDbSources(url)) {
|
||||||
|
if (source.checkUrl(url)) return source.getMigrator(url);
|
||||||
|
}
|
||||||
|
throw new Error(`No source found for url ${url}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
import type { BackendDbInterface } from "../types/DbInterface.js";
|
||||||
|
import {
|
||||||
|
isValidUserPassword,
|
||||||
|
type ClientUser,
|
||||||
|
type CreateUser,
|
||||||
|
type LoginUser,
|
||||||
|
type UpdateUser,
|
||||||
|
type UserFlags,
|
||||||
|
} from "../types/User.js";
|
||||||
|
import { sqliteToClientUser, sqliteToDate, sqliteToUuid } from "./cast.js";
|
||||||
|
import {
|
||||||
|
ConflictAbodeError,
|
||||||
|
InvalidAbodeError,
|
||||||
|
NotAuthorizedAbodeError,
|
||||||
|
NotFoundAbodeError,
|
||||||
|
ReadonlyAbodeError,
|
||||||
|
} from "../types/errors.js";
|
||||||
|
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
||||||
|
import type {
|
||||||
|
CreateResident,
|
||||||
|
Resident,
|
||||||
|
ResidentFlags,
|
||||||
|
updateResident,
|
||||||
|
} from "../types/Resident.js";
|
||||||
|
import { validatePassword } from "../../util/hash.js";
|
||||||
|
import { createApikeyToken, createSessionToken } from "../../util/token.js";
|
||||||
|
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
||||||
|
import { calcUpdates, catSql, joinSql, sql } from "./sql.js";
|
||||||
|
import {
|
||||||
|
selectAbode,
|
||||||
|
selectAbodes,
|
||||||
|
selectClientApikey,
|
||||||
|
selectClientApikeys,
|
||||||
|
selectClientUser,
|
||||||
|
selectClientUsers,
|
||||||
|
selectResident,
|
||||||
|
selectResidents,
|
||||||
|
} from "./query.js";
|
||||||
|
import type {
|
||||||
|
CreateNote,
|
||||||
|
Note,
|
||||||
|
PartialNote,
|
||||||
|
UpdateNote,
|
||||||
|
} from "../types/Note.js";
|
||||||
|
import type { WrappedDb } from "./impl/types.js";
|
||||||
|
|
||||||
|
export class SqliteInterface implements BackendDbInterface {
|
||||||
|
#db: WrappedDb;
|
||||||
|
|
||||||
|
constructor(db: WrappedDb) {
|
||||||
|
this.#db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
#checkReadonly(): void {
|
||||||
|
if (this.#db.readonly) throw new ReadonlyAbodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
get _() {
|
||||||
|
return {
|
||||||
|
sql,
|
||||||
|
catSql,
|
||||||
|
joinSql,
|
||||||
|
db: this.#db,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get readonly(): boolean {
|
||||||
|
return this.#db.readonly;
|
||||||
|
}
|
||||||
|
get backend(): true {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
get name(): "sqlite" {
|
||||||
|
return "sqlite";
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.#db.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers(): Promise<ClientUser[]> {
|
||||||
|
return selectClientUsers(this.#db);
|
||||||
|
}
|
||||||
|
#getUserById(uid: string): ClientUser {
|
||||||
|
const user = selectClientUser(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||||
|
if (!user) throw new NotFoundAbodeError();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
async getUserById(id: string): Promise<ClientUser> {
|
||||||
|
return this.#getUserById(id);
|
||||||
|
}
|
||||||
|
async getUserByEmail(email: string): Promise<ClientUser> {
|
||||||
|
const user = selectClientUser(this.#db, sql`"email" = ${{ text: email }}`);
|
||||||
|
if (!user) throw new NotFoundAbodeError();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
|
||||||
|
const rawUser = this.#db.get<{
|
||||||
|
uid: Buffer;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
flags: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
password: string;
|
||||||
|
}>(
|
||||||
|
sql`
|
||||||
|
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
|
||||||
|
FROM "users"
|
||||||
|
WHERE "email" = ${{ text: email }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!rawUser) throw new NotFoundAbodeError();
|
||||||
|
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
|
||||||
|
if (!(await validatePassword(password, rawUser.password)))
|
||||||
|
throw new NotAuthorizedAbodeError();
|
||||||
|
return sqliteToClientUser(rawUser);
|
||||||
|
}
|
||||||
|
async deleteUserById(id: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = this.#db.run(
|
||||||
|
sql`
|
||||||
|
DELETE FROM "users"
|
||||||
|
WHERE "uid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
async createUser(user: CreateUser): Promise<ClientUser> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
|
||||||
|
const uid = crypto.randomUUID();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(() => {
|
||||||
|
this.#db.run(
|
||||||
|
sql`
|
||||||
|
INSERT INTO "users"("uid", "email", "name", "password", "flags")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: uid }},
|
||||||
|
${{ text: user.email }},
|
||||||
|
${{ text: user.name }},
|
||||||
|
${{ text: user.password }},${{ jsonb: user.flags }})
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return this.#getUserById(uid);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(() => {
|
||||||
|
const updates = calcUpdates({
|
||||||
|
email: (value: string) => sql`"email" = ${{ text: value }}`,
|
||||||
|
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||||
|
password: (value: string) => {
|
||||||
|
if (!isValidUserPassword(value)) throw new InvalidAbodeError();
|
||||||
|
if (value.startsWith("#")) {
|
||||||
|
this.#db.run(sql`
|
||||||
|
DELETE FROM "apikeys"
|
||||||
|
WHERE "uid" = ${{ uuid: user.uid }}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
this.#db.run(sql`
|
||||||
|
DELETE FROM "sessions"
|
||||||
|
WHERE "uid" = ${{ uuid: user.uid }}
|
||||||
|
`);
|
||||||
|
return sql`"password" = ${{ text: value }}`;
|
||||||
|
},
|
||||||
|
flags: (value: UserFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||||
|
})(user);
|
||||||
|
if (!updates.length) throw new InvalidAbodeError();
|
||||||
|
const { changes } = this.#db.run(
|
||||||
|
sql`
|
||||||
|
UPDATE "users"
|
||||||
|
SET
|
||||||
|
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||||
|
${joinSql(updates, sql`, `)}
|
||||||
|
WHERE "uid" = ${{ uuid: user.uid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
return this.#getUserById(user.uid);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAbodes(): Promise<Abode[]> {
|
||||||
|
return selectAbodes(this.#db);
|
||||||
|
}
|
||||||
|
#getAbodeById(aid: string): Abode {
|
||||||
|
const abode = selectAbode(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||||
|
if (!abode) throw new NotFoundAbodeError();
|
||||||
|
return abode;
|
||||||
|
}
|
||||||
|
async getAbodeById(id: string): Promise<Abode> {
|
||||||
|
return this.#getAbodeById(id);
|
||||||
|
}
|
||||||
|
async deleteAbodeById(id: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = this.#db.run(
|
||||||
|
sql`
|
||||||
|
DELETE FROM "abodes"
|
||||||
|
WHERE "aid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const aid = crypto.randomUUID();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(() => {
|
||||||
|
this.#db.run(
|
||||||
|
sql`
|
||||||
|
INSERT INTO "abodes"("aid", "name", "created_by", "updated_by")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: aid }},
|
||||||
|
${{ text: abode.name }},
|
||||||
|
${{ uuid: ctx.uid }},
|
||||||
|
${{ uuid: ctx.uid }}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return this.#getAbodeById(aid);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const updates = calcUpdates({
|
||||||
|
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||||
|
})(abode);
|
||||||
|
if (!updates.length) throw new InvalidAbodeError();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(() => {
|
||||||
|
const { changes } = this.#db.run(
|
||||||
|
sql`
|
||||||
|
UPDATE "abodes"
|
||||||
|
SET
|
||||||
|
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||||
|
"updated_by" = ${{ uuid: ctx.uid }},
|
||||||
|
${joinSql(updates, sql`, `)}
|
||||||
|
WHERE "aid" = ${{ uuid: abode.aid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
return this.#getAbodeById(abode.aid);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResidents(): Promise<Resident[]> {
|
||||||
|
return selectResidents(this.#db);
|
||||||
|
}
|
||||||
|
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
||||||
|
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||||
|
}
|
||||||
|
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
||||||
|
return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||||
|
}
|
||||||
|
#getResidentById(uid: string, aid: string): Resident {
|
||||||
|
const resident = selectResident(
|
||||||
|
this.#db,
|
||||||
|
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`
|
||||||
|
);
|
||||||
|
if (!resident) throw new NotFoundAbodeError();
|
||||||
|
return resident;
|
||||||
|
}
|
||||||
|
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
||||||
|
return this.#getResidentById(uid, aid);
|
||||||
|
}
|
||||||
|
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = this.#db.run(
|
||||||
|
sql`
|
||||||
|
DELETE FROM "residents"
|
||||||
|
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
async createResident(
|
||||||
|
resident: CreateResident,
|
||||||
|
ctx: { uid: string }
|
||||||
|
): Promise<Resident> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(() => {
|
||||||
|
this.#db.run(
|
||||||
|
sql`
|
||||||
|
INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: resident.uid }},
|
||||||
|
${{ uuid: resident.aid }},
|
||||||
|
${{ jsonb: resident.flags }},
|
||||||
|
${{ uuid: ctx.uid }},
|
||||||
|
${{ uuid: ctx.uid }}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return this.#getResidentById(resident.uid, resident.aid);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async updateResident(
|
||||||
|
resident: updateResident,
|
||||||
|
ctx: { uid: string }
|
||||||
|
): Promise<Resident> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const updates = calcUpdates({
|
||||||
|
flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||||
|
})(resident);
|
||||||
|
if (!updates.length) throw new InvalidAbodeError();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(() => {
|
||||||
|
const { changes } = this.#db.run(
|
||||||
|
sql`
|
||||||
|
UPDATE "residents"
|
||||||
|
SET
|
||||||
|
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||||
|
"updated_by" = ${{ uuid: ctx.uid }}
|
||||||
|
${joinSql(updates, sql`, `)}
|
||||||
|
WHERE
|
||||||
|
"uid" = ${{ uuid: resident.uid }}
|
||||||
|
AND "aid" = ${{ uuid: resident.aid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
return this.#getResidentById(resident.uid, resident.aid);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsersByAbodeId(id: string): Promise<ClientUser[]> {
|
||||||
|
return selectClientUsers(
|
||||||
|
this.#db,
|
||||||
|
sql`
|
||||||
|
JOIN "residents" r ON u."uid" = r."uid"
|
||||||
|
WHERE r."aid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
||||||
|
return selectAbodes(
|
||||||
|
this.#db,
|
||||||
|
sql`
|
||||||
|
JOIN "residents" r ON a."aid" = r."aid"
|
||||||
|
WHERE r."uid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
|
||||||
|
const session = this.#db.get<{ uid: Buffer; expires_at: string }>(sql`
|
||||||
|
SELECT "uid", "expires_at"
|
||||||
|
FROM "sessions"
|
||||||
|
WHERE "token" = ${{ text: token }}
|
||||||
|
`);
|
||||||
|
if (!session) throw new NotFoundAbodeError();
|
||||||
|
if (new Date(sqliteToDate(session.expires_at)).getTime() < Date.now()) {
|
||||||
|
if (!this.readonly) {
|
||||||
|
this.#db.run(sql`
|
||||||
|
DELETE FROM "sessions"
|
||||||
|
WHERE "expires_at" < datetime('now', 'localtime', 'subsec')
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
if (!this.readonly) {
|
||||||
|
this.#db.run(sql`
|
||||||
|
UPDATE "sessions"
|
||||||
|
SET "expires_at" = datetime('now', 'localtime', 'subsec', '+7 days')
|
||||||
|
WHERE "token" = ${{ text: token }}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return this.#getUserById(sqliteToUuid(session.uid));
|
||||||
|
}
|
||||||
|
async createSession(uid: string): Promise<`as_${string}`> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const token = createSessionToken();
|
||||||
|
this.#db.run(sql`
|
||||||
|
INSERT INTO "sessions"("uid", "token")
|
||||||
|
VALUES(${{ uuid: uid }}, ${{ text: token }})
|
||||||
|
`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
async deleteSessionsByUser(uid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
this.#db.run(sql`
|
||||||
|
DELETE FROM "sessions"
|
||||||
|
WHERE "uid" = ${{ uuid: uid }}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
#getApikeyByToken(token: `at_${string}`): ClientApikey {
|
||||||
|
const apikey = selectClientApikey(
|
||||||
|
this.#db,
|
||||||
|
sql`"token" = ${{ text: token }}`
|
||||||
|
);
|
||||||
|
if (!apikey) throw new NotFoundAbodeError();
|
||||||
|
return apikey;
|
||||||
|
}
|
||||||
|
async getUserByApikey(
|
||||||
|
token: `at_${string}`
|
||||||
|
): Promise<[ClientUser, ClientApikey]> {
|
||||||
|
const apikey = this.#getApikeyByToken(token);
|
||||||
|
if (
|
||||||
|
apikey.expires_at &&
|
||||||
|
new Date(apikey.expires_at).getTime() < Date.now()
|
||||||
|
) {
|
||||||
|
throw new NotAuthorizedAbodeError();
|
||||||
|
}
|
||||||
|
return [this.#getUserById(apikey.uid), apikey];
|
||||||
|
}
|
||||||
|
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
||||||
|
return selectClientApikeys(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||||
|
}
|
||||||
|
async getApikeyById(kid: string): Promise<ClientApikey> {
|
||||||
|
const apikey = selectClientApikey(this.#db, sql`"kid" = ${{ uuid: kid }}`);
|
||||||
|
if (!apikey) throw new NotFoundAbodeError();
|
||||||
|
return apikey;
|
||||||
|
}
|
||||||
|
async createApikey(
|
||||||
|
apikey: CreateApikey
|
||||||
|
): Promise<[ClientApikey, `at_${string}`]> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const token = createApikeyToken();
|
||||||
|
const kid = crypto.randomUUID();
|
||||||
|
let expires = apikey.expires_at;
|
||||||
|
if (expires === undefined)
|
||||||
|
expires = new Date(
|
||||||
|
new Date().getTime() + 1000 * 60 * 60 * 24 * 365
|
||||||
|
).toISOString();
|
||||||
|
if (expires && new Date(expires).getTime() < Date.now())
|
||||||
|
throw new InvalidAbodeError();
|
||||||
|
|
||||||
|
this.#db.run(sql`
|
||||||
|
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: apikey.uid }},
|
||||||
|
${{ uuid: kid }},
|
||||||
|
${{ text: token }},
|
||||||
|
${{ text: apikey.name }},
|
||||||
|
${{ jsonb: apikey.permissions }},
|
||||||
|
${expires ? { date: expires } : { null: true }}
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
return [this.#getApikeyByToken(token), token];
|
||||||
|
}
|
||||||
|
async deleteApikeyById(kid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = this.#db.run(sql`
|
||||||
|
DELETE FROM "apikeys"
|
||||||
|
WHERE "kid" = ${{ uuid: kid }}
|
||||||
|
`);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNotes(): Promise<PartialNote[]> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async getNoteById(nid: string): Promise<Note> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async deleteNoteById(nid: string): Promise<void> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||||
|
throw new Error("Unimplemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type {
|
||||||
|
AppliedMigration,
|
||||||
|
AvailableMigration,
|
||||||
|
Migrator,
|
||||||
|
} from "../types/Migrator.js";
|
||||||
|
import { init, migrations } from "./migrations/index.js";
|
||||||
|
import { sqliteToDate } from "./cast.js";
|
||||||
|
import type { WrappedDb } from "./impl/types.js";
|
||||||
|
import { sql, unsafeSql } from "./sql.js";
|
||||||
|
|
||||||
|
export class SqliteMigrator implements Migrator {
|
||||||
|
#db: WrappedDb;
|
||||||
|
|
||||||
|
constructor(db: WrappedDb) {
|
||||||
|
this.#db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
#listAppliedMigrations():
|
||||||
|
| { id: number; name: string; applied_at: string }[]
|
||||||
|
| null {
|
||||||
|
try {
|
||||||
|
return this.#db
|
||||||
|
.all<{ id: number; name: string; applied_at: string }>(
|
||||||
|
sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`
|
||||||
|
)
|
||||||
|
.map((x) => ({ ...x, applied_at: sqliteToDate(x.applied_at) }));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "no such table: _migrations") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAppliedMigrations(): Promise<AppliedMigration[]> {
|
||||||
|
return this.#listAppliedMigrations() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
listAvailableMigrations(): AvailableMigration[] {
|
||||||
|
return migrations.map((migration) => ({
|
||||||
|
id: migration.id,
|
||||||
|
name: migration.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateTo(id: number): Promise<void> {
|
||||||
|
const target = migrations.find((x) => x.id === id);
|
||||||
|
if (!target) throw new Error(`No known migration with id ${id}`);
|
||||||
|
|
||||||
|
let current = this.#listAppliedMigrations();
|
||||||
|
if (!current) {
|
||||||
|
this.#db.run(unsafeSql(init));
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { id, name } of current) {
|
||||||
|
const migration = migrations.find((x) => x.id === id);
|
||||||
|
if (!migration)
|
||||||
|
throw new Error(`Applied migration ${id} (${name}) not known`);
|
||||||
|
if (migration.name !== name)
|
||||||
|
throw new Error(
|
||||||
|
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
|
||||||
|
const end = migrations.indexOf(target) + 1;
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${
|
||||||
|
target.id
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toApply = migrations.slice(start, end);
|
||||||
|
|
||||||
|
if (!toApply.length) {
|
||||||
|
console.log("Nothing to do");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const migration of toApply) {
|
||||||
|
try {
|
||||||
|
console.log(`Applying migration ${migration.id} (${migration.name})`);
|
||||||
|
this.#db.run(sql`BEGIN`);
|
||||||
|
for (const part of migration.parts) {
|
||||||
|
console.log(`- Applying part ${part.id} (${part.name})`);
|
||||||
|
if ("sql" in part) this.#db.run(unsafeSql(part.sql));
|
||||||
|
else await part.apply(this.#db);
|
||||||
|
}
|
||||||
|
this.#db.run(
|
||||||
|
sql`
|
||||||
|
INSERT INTO "_migrations"("id", "name")
|
||||||
|
VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
|
||||||
|
`
|
||||||
|
);
|
||||||
|
this.#db.run(sql`COMMIT`);
|
||||||
|
} catch (e) {
|
||||||
|
this.#db.run(sql`ROLLBACK`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done migrating database");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { Abode } from "../types/Abode.js";
|
||||||
|
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
|
||||||
|
import type { Resident, ResidentFlags } from "../types/Resident.js";
|
||||||
|
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
|
||||||
|
|
||||||
|
export function dateToSqlite(date: string) {
|
||||||
|
return new Date(date).getTime() / 1000;
|
||||||
|
}
|
||||||
|
export function sqliteToDate(date: string) {
|
||||||
|
return new Date(date.replace(" ", "T") + "Z").toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuidToSqlite(uuid: string) {
|
||||||
|
return Buffer.from(uuid.replaceAll("-", ""), "hex");
|
||||||
|
}
|
||||||
|
export function sqliteToUuid(uuid: Buffer | Uint8Array) {
|
||||||
|
const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString(
|
||||||
|
"hex"
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
hex.slice(0, 8),
|
||||||
|
hex.slice(8, 12),
|
||||||
|
hex.slice(12, 16),
|
||||||
|
hex.slice(16, 20),
|
||||||
|
hex.slice(20),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUserFlags: UserFlags = {};
|
||||||
|
export function sqliteToUserFlags(flags: string): UserFlags {
|
||||||
|
const parsed = JSON.parse(flags);
|
||||||
|
const out = { ...defaultUserFlags };
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || !parsed || Array.isArray(parsed))
|
||||||
|
return out;
|
||||||
|
|
||||||
|
if (parsed.admin === true) out.admin = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteToPartialUser(user: {
|
||||||
|
uid: Buffer | Uint8Array;
|
||||||
|
name: string;
|
||||||
|
flags: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}): PartialUser {
|
||||||
|
return {
|
||||||
|
uid: sqliteToUuid(user.uid),
|
||||||
|
name: user.name,
|
||||||
|
flags: sqliteToUserFlags(user.flags),
|
||||||
|
created_at: sqliteToDate(user.created_at),
|
||||||
|
updated_at: sqliteToDate(user.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function sqliteToClientUser(user: {
|
||||||
|
uid: Buffer | Uint8Array;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
flags: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}): ClientUser {
|
||||||
|
return {
|
||||||
|
...sqliteToPartialUser(user),
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteToAbode(abode: {
|
||||||
|
aid: Buffer | Uint8Array;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
created_by: Buffer | Uint8Array | null;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: Buffer | Uint8Array | null;
|
||||||
|
}): Abode {
|
||||||
|
return {
|
||||||
|
aid: sqliteToUuid(abode.aid),
|
||||||
|
name: abode.name,
|
||||||
|
created_at: sqliteToDate(abode.created_at),
|
||||||
|
created_by: abode.created_by && sqliteToUuid(abode.created_by),
|
||||||
|
updated_at: sqliteToDate(abode.updated_at),
|
||||||
|
updated_by: abode.updated_by && sqliteToUuid(abode.updated_by),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultResidentFlags: ResidentFlags = {};
|
||||||
|
export function sqliteToResidentFlags(flags: string): ResidentFlags {
|
||||||
|
const parsed = JSON.parse(flags);
|
||||||
|
const out = { ...defaultResidentFlags };
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || !parsed || Array.isArray(parsed))
|
||||||
|
return out;
|
||||||
|
|
||||||
|
if (parsed.admin === true) out.admin = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteToResident(resident: {
|
||||||
|
uid: Buffer | Uint8Array;
|
||||||
|
aid: Buffer | Uint8Array;
|
||||||
|
flags: string;
|
||||||
|
created_at: string;
|
||||||
|
created_by: Buffer | Uint8Array | null;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: Buffer | Uint8Array | null;
|
||||||
|
}): Resident {
|
||||||
|
return {
|
||||||
|
uid: sqliteToUuid(resident.uid),
|
||||||
|
aid: sqliteToUuid(resident.aid),
|
||||||
|
flags: sqliteToResidentFlags(resident.flags),
|
||||||
|
created_at: sqliteToDate(resident.created_at),
|
||||||
|
created_by: resident.created_by && sqliteToUuid(resident.created_by),
|
||||||
|
updated_at: sqliteToDate(resident.updated_at),
|
||||||
|
updated_by: resident.updated_by && sqliteToUuid(resident.updated_by),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultApikeyPermissions: ApikeyPermissions = {};
|
||||||
|
export function sqliteToApikeyPermissions(
|
||||||
|
permissions: string
|
||||||
|
): ApikeyPermissions {
|
||||||
|
const parsed = JSON.parse(permissions);
|
||||||
|
const out = { ...defaultApikeyPermissions };
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || !parsed || Array.isArray(parsed))
|
||||||
|
return out;
|
||||||
|
|
||||||
|
if (parsed.admin === true) out.admin = true;
|
||||||
|
if (parsed.all === true) out.all = true;
|
||||||
|
for (const key of ["users", "residents", "abodes"] as const) {
|
||||||
|
if (parsed[key] === "r" || parsed[key] === "rw") out[key] = parsed[key];
|
||||||
|
}
|
||||||
|
for (const key of ["restrict_users", "restrict_abodes"] as const) {
|
||||||
|
if (
|
||||||
|
Array.isArray(parsed[key]) &&
|
||||||
|
parsed[key].every((x) => typeof x === "string")
|
||||||
|
) {
|
||||||
|
out[key] = parsed[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteToClientApikey(apikey: {
|
||||||
|
uid: Buffer | Uint8Array;
|
||||||
|
kid: Buffer | Uint8Array;
|
||||||
|
name: string;
|
||||||
|
permissions: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
}): ClientApikey {
|
||||||
|
return {
|
||||||
|
uid: sqliteToUuid(apikey.uid),
|
||||||
|
kid: sqliteToUuid(apikey.kid),
|
||||||
|
name: apikey.name,
|
||||||
|
permissions: sqliteToApikeyPermissions(apikey.permissions),
|
||||||
|
created_at: sqliteToDate(apikey.created_at),
|
||||||
|
expires_at: apikey.expires_at ? sqliteToDate(apikey.expires_at) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { GetDbDynamic } from "../types/GetDb.js";
|
||||||
|
import { sqliteProtocols } from "./url.js";
|
||||||
|
|
||||||
|
const getSqlite = () =>
|
||||||
|
import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then(
|
||||||
|
(x) => x.default
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSqliteDynamic: GetDbDynamic = {
|
||||||
|
name: "sqlite",
|
||||||
|
protocols: sqliteProtocols,
|
||||||
|
getSource: getSqlite,
|
||||||
|
};
|
||||||
|
export default getSqliteDynamic;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { GetDbStatic } from "../types/GetDb.js";
|
||||||
|
import { getWrappedDb } from "./impl/index.js";
|
||||||
|
import { SqliteInterface } from "./SqliteInterface.js";
|
||||||
|
import { SqliteMigrator } from "./SqliteMigrator.js";
|
||||||
|
import { isSqliteUrl, parseSqliteUrl, sqliteProtocols } from "./url.js";
|
||||||
|
|
||||||
|
const getSqliteStatic: GetDbStatic = {
|
||||||
|
name: "sqlite",
|
||||||
|
protocols: sqliteProtocols,
|
||||||
|
checkUrl: isSqliteUrl,
|
||||||
|
getDbInterface: async (url) =>
|
||||||
|
new SqliteInterface(getWrappedDb(...parseSqliteUrl(url))),
|
||||||
|
getMigrator: async (url) =>
|
||||||
|
new SqliteMigrator(getWrappedDb(...parseSqliteUrl(url))),
|
||||||
|
};
|
||||||
|
export default getSqliteStatic;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ConflictAbodeError, NotFoundAbodeError } from "../../types/errors.js";
|
||||||
|
import type { SqlCode, SqlVar } from "../sql.js";
|
||||||
|
import Sqlite, * as sqlite from "better-sqlite3";
|
||||||
|
import pragma from "../pragma.sqlite.sql";
|
||||||
|
import type { WrappedDb, WrappedDbOptions } from "./types.js";
|
||||||
|
|
||||||
|
function getDatabase(
|
||||||
|
path: string,
|
||||||
|
options?: Omit<sqlite.Options, "nativeBinding">
|
||||||
|
): sqlite.Database {
|
||||||
|
if (!natives.sqlite) throw new Error("No natives found for better-sqlite3");
|
||||||
|
options = { ...options };
|
||||||
|
if (typeof options.timeout !== "number") delete options.timeout;
|
||||||
|
const db = new Sqlite(path, { ...options, nativeBinding: natives.sqlite });
|
||||||
|
db.exec(pragma);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rethrow<R>(fn: () => R): R {
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof sqlite.SqliteError) {
|
||||||
|
switch (e.code) {
|
||||||
|
case "SQLITE_CONSTRAINT_UNIQUE":
|
||||||
|
case "SQLITE_CONSTRAINT_PRIMARYKEY":
|
||||||
|
throw new ConflictAbodeError();
|
||||||
|
|
||||||
|
case "SQLITE_CONSTRAINT_FOREIGNKEY":
|
||||||
|
throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WrappedBetterSqlite3Db implements WrappedDb {
|
||||||
|
#db: sqlite.Database;
|
||||||
|
#statements: Map<string, sqlite.Statement>;
|
||||||
|
|
||||||
|
constructor(path: string, options: WrappedDbOptions = {}) {
|
||||||
|
this.#db = getDatabase(path, {
|
||||||
|
readonly: options.readonly,
|
||||||
|
timeout: options.timeout,
|
||||||
|
});
|
||||||
|
this.#statements = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
get _db() {
|
||||||
|
return this.#db;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readonly(): boolean {
|
||||||
|
return this.#db.readonly;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.#db.close();
|
||||||
|
this.#statements.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#stmt<R>(stmt: string): sqlite.Statement<SqlVar[], R> {
|
||||||
|
if (!this.#db.open) throw new Error("Db is closed");
|
||||||
|
let prep = this.#statements.get(stmt);
|
||||||
|
if (!prep) {
|
||||||
|
prep = this.#db.prepare<(string | Buffer)[], R>(stmt);
|
||||||
|
this.#statements.set(stmt, prep);
|
||||||
|
}
|
||||||
|
return prep as sqlite.Statement<string | Buffer, R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
all<R>(stmt: SqlCode): R[] {
|
||||||
|
return this.#stmt<R>(stmt._sql).all(...stmt._vars);
|
||||||
|
}
|
||||||
|
get<R>(stmt: SqlCode): R | null {
|
||||||
|
const results = this.all<R>(stmt);
|
||||||
|
if (results.length > 1) throw new Error("Multiple results");
|
||||||
|
if (!results.length) return null;
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
run(stmt: SqlCode): { changes: number } {
|
||||||
|
return this.#stmt<void>(stmt._sql).run(...stmt._vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
multi<R>(fn: () => R): R {
|
||||||
|
return this.#db.transaction(fn)();
|
||||||
|
}
|
||||||
|
rethrow = rethrow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { WrappedBetterSqlite3Db } from "./better-sqlite3.js";
|
||||||
|
import { WrappedNodeSqliteDb } from "./node-sqlite.js";
|
||||||
|
import type { WrappedDbConstructor } from "./types.js";
|
||||||
|
|
||||||
|
export const node: WrappedDbConstructor | null = WrappedNodeSqliteDb;
|
||||||
|
export const bs3: WrappedDbConstructor | null = WrappedBetterSqlite3Db;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { WrappedBetterSqlite3Db } from "./better-sqlite3.js";
|
||||||
|
import type { WrappedDbConstructor } from "./types.js";
|
||||||
|
|
||||||
|
export const node: WrappedDbConstructor | null = null;
|
||||||
|
export const bs3: WrappedDbConstructor | null = WrappedBetterSqlite3Db;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { WrappedNodeSqliteDb } from "./node-sqlite.js";
|
||||||
|
import type { WrappedDbConstructor } from "./types.js";
|
||||||
|
|
||||||
|
export const node: WrappedDbConstructor | null = WrappedNodeSqliteDb;
|
||||||
|
export const bs3: WrappedDbConstructor | null = null;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./implementations.all.js";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { WrappedDb, WrappedDbOptions } from "./types.js";
|
||||||
|
import { node, bs3 } from "./implementations.js";
|
||||||
|
|
||||||
|
export function getWrappedDb(
|
||||||
|
kind: "any" | "node" | "bs3",
|
||||||
|
path: string,
|
||||||
|
options: WrappedDbOptions
|
||||||
|
): WrappedDb {
|
||||||
|
if (kind === "node") {
|
||||||
|
if (!node) throw new Error("Requesting unavailable node backend");
|
||||||
|
return new node(path, options);
|
||||||
|
} else if (kind === "bs3") {
|
||||||
|
if (!bs3) throw new Error("Requesting unavailable better-sqlite3 backend");
|
||||||
|
return new bs3(path, options);
|
||||||
|
} else {
|
||||||
|
const impl = [node, bs3].find(Boolean);
|
||||||
|
if (!impl) throw new Error("No available backend");
|
||||||
|
return new impl(path, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { DatabaseSync, StatementSync } from "node:sqlite";
|
||||||
|
import type { WrappedDb, WrappedDbOptions } from "./types.js";
|
||||||
|
import type { SqlCode } from "../sql.js";
|
||||||
|
import pragma from "../pragma.sqlite.sql";
|
||||||
|
|
||||||
|
export class WrappedNodeSqliteDb implements WrappedDb {
|
||||||
|
#db: DatabaseSync;
|
||||||
|
#readonly: boolean;
|
||||||
|
#statements: Map<string, StatementSync>;
|
||||||
|
|
||||||
|
constructor(path: string, options: WrappedDbOptions = {}) {
|
||||||
|
this.#readonly = !!options.readonly;
|
||||||
|
this.#db = new DatabaseSync(path, {
|
||||||
|
readOnly: options.readonly,
|
||||||
|
timeout: options.timeout,
|
||||||
|
open: true,
|
||||||
|
});
|
||||||
|
this.#db.exec(pragma);
|
||||||
|
this.#statements = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
get _db() {
|
||||||
|
return this.#db;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readonly(): boolean {
|
||||||
|
return this.#readonly;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.#db.close();
|
||||||
|
this.#statements.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#stmt(stmt: string): StatementSync {
|
||||||
|
if (!this.#db.open) throw new Error("Db is closed");
|
||||||
|
let prep = this.#statements.get(stmt);
|
||||||
|
if (!prep) {
|
||||||
|
prep = this.#db.prepare(stmt);
|
||||||
|
prep.setReadBigInts(false);
|
||||||
|
this.#statements.set(stmt, prep);
|
||||||
|
}
|
||||||
|
return prep;
|
||||||
|
}
|
||||||
|
|
||||||
|
all<R>(stmt: SqlCode): R[] {
|
||||||
|
return this.#stmt(stmt._sql).all(...stmt._vars) as R[];
|
||||||
|
}
|
||||||
|
get<R>(stmt: SqlCode): R | null {
|
||||||
|
const results = this.all<R>(stmt);
|
||||||
|
if (results.length > 1) throw new Error("Multiple results");
|
||||||
|
if (!results.length) return null;
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
run(stmt: SqlCode): { changes: number } {
|
||||||
|
const { changes } = this.#stmt(stmt._sql).run(...stmt._vars);
|
||||||
|
return { changes: Number(changes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
multi<R>(fn: () => R): R {
|
||||||
|
if (this.#db.isTransaction)
|
||||||
|
throw new Error("Nested transactions not supported");
|
||||||
|
this.#db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
const rst = fn();
|
||||||
|
this.#db.exec("COMMIT");
|
||||||
|
return rst;
|
||||||
|
} catch (e) {
|
||||||
|
this.#db.exec("ROLLBACK");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rethrow<R>(fn: () => R): R {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { SqlCode } from "../sql.js";
|
||||||
|
|
||||||
|
export interface WrappedDb {
|
||||||
|
get readonly(): boolean;
|
||||||
|
destroy(): void;
|
||||||
|
|
||||||
|
all<R>(stmt: SqlCode): R[];
|
||||||
|
get<R>(stmt: SqlCode): R | null;
|
||||||
|
run(stmt: SqlCode): { changes: number };
|
||||||
|
multi<R>(fn: () => R): R;
|
||||||
|
rethrow<R>(fn: () => R): R;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WrappedDbOptions = { readonly?: boolean; timeout?: number };
|
||||||
|
|
||||||
|
export interface WrappedDbConstructor {
|
||||||
|
new (path: string, options?: WrappedDbOptions): WrappedDb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "users" (
|
||||||
|
"uid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||||
|
"email" TEXT NOT NULL UNIQUE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL DEFAULT '#unset',
|
||||||
|
"flags" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||||
|
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE "abodes" (
|
||||||
|
"aid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"created_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||||
|
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"updated_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL -- uuid
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "residents" (
|
||||||
|
"uid" BLOB NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||||
|
"aid" BLOB NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE,
|
||||||
|
"flags" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||||
|
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"created_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||||
|
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"updated_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||||
|
|
||||||
|
PRIMARY KEY("uid", "aid")
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { SqliteMigration } from "../types.js";
|
||||||
|
import p1 from "./1.users.sqlite.sql";
|
||||||
|
import p2 from "./2.abodes.sqlite.sql";
|
||||||
|
import p3 from "./3.residents.sqlite.sql";
|
||||||
|
|
||||||
|
export const m1: SqliteMigration = {
|
||||||
|
id: 1,
|
||||||
|
name: "init",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "users",
|
||||||
|
sql: p1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "abodes",
|
||||||
|
sql: p2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "residents",
|
||||||
|
sql: p3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"uid" BLOB NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE, -- uuid
|
||||||
|
"token" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"expires_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec', '+7 days'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "apikeys" (
|
||||||
|
"uid" BLOB NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE, -- uuid
|
||||||
|
"kid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||||
|
"token" TEXT NOT NULL UNIQUE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"permissions" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||||
|
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"expires_at" TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { SqliteMigration } from "../types.js";
|
||||||
|
import p1 from "./1.sessions.sqlite.sql";
|
||||||
|
import p2 from "./2.apikeys.sqlite.sql";
|
||||||
|
|
||||||
|
export const m2: SqliteMigration = {
|
||||||
|
id: 2,
|
||||||
|
name: "auth",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "sessions",
|
||||||
|
sql: p1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "apikeys",
|
||||||
|
sql: p2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "notes" (
|
||||||
|
"nid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||||
|
"aid" BLOB NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE, -- uuid
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL DEFAULT '', -- markdown
|
||||||
|
"properties" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||||
|
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"created_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||||
|
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||||
|
"updated_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL -- uuid
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { SqliteMigration } from "../types.js";
|
||||||
|
import p1 from "./1.notes.sql";
|
||||||
|
|
||||||
|
export const m3: SqliteMigration = {
|
||||||
|
id: 3,
|
||||||
|
name: "notes",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "notes",
|
||||||
|
sql: p1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { m1 } from "./1.init/index.js";
|
||||||
|
import { m2 } from "./2.auth/index.js";
|
||||||
|
import { m3 } from "./3.notes/index.js";
|
||||||
|
import type { SqliteMigration } from "./types.js";
|
||||||
|
|
||||||
|
export { default as init } from "./init.sqlite.sql";
|
||||||
|
export const migrations: SqliteMigration[] = [m1, m2, m3];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE "_migrations" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"applied_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { WrappedDb } from "../impl/types.js";
|
||||||
|
|
||||||
|
export type SqliteMigrationPart = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
apply: (database: WrappedDb) => Promise<void>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SqliteMigration = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parts: SqliteMigrationPart[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
PRAGMA temp_store = MEMORY;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Abode } from "../types/Abode.js";
|
||||||
|
import type { ClientApikey } from "../types/Apikey.js";
|
||||||
|
import type { Resident } from "../types/Resident.js";
|
||||||
|
import type { ClientUser } from "../types/User.js";
|
||||||
|
import {
|
||||||
|
sqliteToAbode,
|
||||||
|
sqliteToClientApikey,
|
||||||
|
sqliteToClientUser,
|
||||||
|
sqliteToResident,
|
||||||
|
} from "./cast.js";
|
||||||
|
import type { WrappedDb } from "./impl/types.js";
|
||||||
|
import { sql, type SqlCode } from "./sql.js";
|
||||||
|
|
||||||
|
type RawClientUser = {
|
||||||
|
uid: Buffer | Uint8Array;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
flags: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
const sqlClientUser = sql`
|
||||||
|
SELECT u."uid", u."email", u."name", json(u."flags") AS "flags", u."created_at", u."updated_at"
|
||||||
|
FROM "users" u
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function selectClientUser(
|
||||||
|
db: WrappedDb,
|
||||||
|
where: SqlCode
|
||||||
|
): ClientUser | null {
|
||||||
|
const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
|
||||||
|
if (rawUser) return sqliteToClientUser(rawUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] {
|
||||||
|
const rawUsers = db.all<RawClientUser>(
|
||||||
|
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser
|
||||||
|
);
|
||||||
|
return rawUsers.map(sqliteToClientUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawAbode = {
|
||||||
|
aid: Buffer | Uint8Array;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
created_by: Buffer | Uint8Array | null;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: Buffer | Uint8Array | null;
|
||||||
|
};
|
||||||
|
const sqlAbode = sql`
|
||||||
|
SELECT a."aid", a."name", a."created_at", a."created_by", a."updated_at", a."updated_by"
|
||||||
|
FROM "abodes" a
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function selectAbode(db: WrappedDb, where: SqlCode): Abode | null {
|
||||||
|
const rawAbode = db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`);
|
||||||
|
if (rawAbode) return sqliteToAbode(rawAbode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] {
|
||||||
|
const rawAbodes = db.all<RawAbode>(
|
||||||
|
rest ? sql`${sqlAbode} ${rest}` : sqlAbode
|
||||||
|
);
|
||||||
|
return rawAbodes.map(sqliteToAbode);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawResident = {
|
||||||
|
uid: Buffer | Uint8Array;
|
||||||
|
aid: Buffer | Uint8Array;
|
||||||
|
flags: string;
|
||||||
|
created_at: string;
|
||||||
|
created_by: Buffer | Uint8Array | null;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: Buffer | Uint8Array | null;
|
||||||
|
};
|
||||||
|
const sqlResident = sql`
|
||||||
|
SELECT "uid", "aid", json("flags") AS "flags", "created_at", "created_by", "updated_at", "updated_by"
|
||||||
|
FROM "residents"
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function selectResident(db: WrappedDb, where: SqlCode): Resident | null {
|
||||||
|
const rawResident = db.get<RawResident>(sql`${sqlResident} WHERE ${where}`);
|
||||||
|
if (rawResident) return sqliteToResident(rawResident);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] {
|
||||||
|
const rawResidents = db.all<RawResident>(
|
||||||
|
where ? sql`${sqlResident} WHERE ${where}` : sqlResident
|
||||||
|
);
|
||||||
|
return rawResidents.map(sqliteToResident);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawClientApikey = {
|
||||||
|
uid: Buffer | Uint8Array;
|
||||||
|
kid: Buffer | Uint8Array;
|
||||||
|
name: string;
|
||||||
|
permissions: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
};
|
||||||
|
const sqlClientApikey = sql`
|
||||||
|
SELECT k."uid", k."kid", k."name", json(k."permissions") AS "permissions", k."created_at", k."expires_at"
|
||||||
|
FROM "apikeys" k
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function selectClientApikey(
|
||||||
|
db: WrappedDb,
|
||||||
|
where: SqlCode
|
||||||
|
): ClientApikey | null {
|
||||||
|
const rawApikey = db.get<RawClientApikey>(
|
||||||
|
sql`${sqlClientApikey} WHERE ${where}`
|
||||||
|
);
|
||||||
|
if (rawApikey) return sqliteToClientApikey(rawApikey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function selectClientApikeys(
|
||||||
|
db: WrappedDb,
|
||||||
|
where: SqlCode
|
||||||
|
): ClientApikey[] {
|
||||||
|
const rawApikeys = db.all<RawClientApikey>(
|
||||||
|
sql`${sqlClientApikey} WHERE ${where}`
|
||||||
|
);
|
||||||
|
return rawApikeys.map(sqliteToClientApikey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { uuidToSqlite } from "./cast.js";
|
||||||
|
|
||||||
|
export type SqlVar = string | Buffer | number;
|
||||||
|
export type SqlCode = { _sql: string; _vars: SqlVar[] };
|
||||||
|
type SqlArg =
|
||||||
|
| { uuid: string }
|
||||||
|
| { text: string }
|
||||||
|
| { jsonb: unknown }
|
||||||
|
| { date: string }
|
||||||
|
| { int: number }
|
||||||
|
| { null: true }
|
||||||
|
| SqlCode;
|
||||||
|
export function sql(text: TemplateStringsArray, ...args: SqlArg[]): SqlCode {
|
||||||
|
let code = "";
|
||||||
|
const vars: SqlVar[] = [];
|
||||||
|
for (const [i, part] of text.entries()) {
|
||||||
|
code += part;
|
||||||
|
if (i < args.length) {
|
||||||
|
const arg = args[i];
|
||||||
|
if ("uuid" in arg) {
|
||||||
|
code += "?";
|
||||||
|
vars.push(uuidToSqlite(arg.uuid));
|
||||||
|
} else if ("text" in arg) {
|
||||||
|
code += "?";
|
||||||
|
vars.push(arg.text);
|
||||||
|
} else if ("jsonb" in arg) {
|
||||||
|
code += "jsonb(?)";
|
||||||
|
vars.push(JSON.stringify(arg.jsonb));
|
||||||
|
} else if ("date" in arg) {
|
||||||
|
code += "datetime(?, 'unixepoch', 'subsec')";
|
||||||
|
vars.push(new Date(arg.date).getTime() / 1000 + "");
|
||||||
|
} else if ("int" in arg) {
|
||||||
|
if (arg.int % 1) throw new Error("Not an integer");
|
||||||
|
code += "?";
|
||||||
|
vars.push(arg.int);
|
||||||
|
} else if ("null" in arg) {
|
||||||
|
code += "NULL";
|
||||||
|
} else {
|
||||||
|
code += arg._sql;
|
||||||
|
for (const v of arg._vars) vars.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { _sql: code, _vars: vars };
|
||||||
|
}
|
||||||
|
export function catSql(a: SqlCode, b: SqlCode): SqlCode {
|
||||||
|
return {
|
||||||
|
_sql: a._sql + b._sql,
|
||||||
|
_vars: [...a._vars, ...b._vars],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function joinSql(parts: SqlCode[], joiner: SqlCode): SqlCode {
|
||||||
|
return parts.reduce((a, b) => catSql(catSql(a, joiner), b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsafeSql(sql: string): SqlCode {
|
||||||
|
return { _sql: sql, _vars: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcUpdates<T extends object>(updater: {
|
||||||
|
[K in keyof T]: (value: NonNullable<T[K]>) => SqlCode;
|
||||||
|
}): (obj: Partial<T>) => SqlCode[] {
|
||||||
|
return (obj) => {
|
||||||
|
const updates: SqlCode[] = [];
|
||||||
|
for (const [prop, update] of Object.entries(updater)) {
|
||||||
|
if (prop in obj) {
|
||||||
|
updates.push(
|
||||||
|
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { WrappedDbOptions } from "./impl/types.js";
|
||||||
|
|
||||||
|
function checkBooleanParam(url: URL, param: string): boolean {
|
||||||
|
return (url.searchParams.get(param) ?? "0") !== "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNumberParam(url: URL, param: string): number | undefined {
|
||||||
|
const value = url.searchParams.get(param);
|
||||||
|
if (!value || isNaN(+value)) return;
|
||||||
|
return +value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sqliteProtocols = [
|
||||||
|
"sqlite:",
|
||||||
|
"sqlite3:",
|
||||||
|
"node+sqlite:",
|
||||||
|
"bs3+sqlite:",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isSqliteUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return sqliteProtocols.includes(urlObj.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSqliteUrl(
|
||||||
|
url: string
|
||||||
|
): ["any" | "node" | "bs3", string, WrappedDbOptions] {
|
||||||
|
if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol");
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const options: WrappedDbOptions = {
|
||||||
|
readonly: checkBooleanParam(urlObj, "readonly"),
|
||||||
|
timeout: checkNumberParam(urlObj, "timeout"),
|
||||||
|
};
|
||||||
|
const kind =
|
||||||
|
urlObj.protocol === "node+sqlite:"
|
||||||
|
? "node"
|
||||||
|
: urlObj.protocol === "bs3+sqlite:"
|
||||||
|
? "bs3"
|
||||||
|
: "any";
|
||||||
|
return [kind, urlObj.pathname, options];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { GetDbDynamic, GetDbStatic } from "./types/GetDb.js";
|
||||||
|
|
||||||
|
const getStub: GetDbStatic & GetDbDynamic = {
|
||||||
|
name: "stub",
|
||||||
|
protocols: [],
|
||||||
|
checkUrl: () => false,
|
||||||
|
getDbInterface: async () => {
|
||||||
|
throw new Error("Stub db interface");
|
||||||
|
},
|
||||||
|
getMigrator: async () => {
|
||||||
|
throw new Error("Stub db interface");
|
||||||
|
},
|
||||||
|
getSource: async () => null,
|
||||||
|
};
|
||||||
|
export default getStub;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Create, Update } from "./utils.js";
|
||||||
|
|
||||||
|
export type Abode = {
|
||||||
|
aid: string; // PK, uuid
|
||||||
|
name: string;
|
||||||
|
created_at: string; // ISO datetime
|
||||||
|
created_by: string | null; // uuid, FK Users.uid
|
||||||
|
updated_at: string; // ISO datetime
|
||||||
|
updated_by: string | null; // uuid, FK Users.uid
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateAbode = Create<Abode, "aid">;
|
||||||
|
export type UpdateAbode = Update<Abode, "aid">;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Create } from "./utils.js";
|
||||||
|
|
||||||
|
export type ApikeyPermissions = {
|
||||||
|
admin?: boolean;
|
||||||
|
all?: boolean;
|
||||||
|
users?: "r" | "rw";
|
||||||
|
residents?: "r" | "rw";
|
||||||
|
abodes?: "r" | "rw";
|
||||||
|
restrict_users?: string[]; // uuid, FK users.uid
|
||||||
|
restrict_abodes?: string[]; // uuid, FK abodes.aid
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Apikey = {
|
||||||
|
uid: string; // uuid, FK users.uid
|
||||||
|
kid: string; // uuid, PK
|
||||||
|
token: string; // unique
|
||||||
|
name: string;
|
||||||
|
permissions: ApikeyPermissions;
|
||||||
|
created_at: string; // ISO datetime
|
||||||
|
expires_at: string | null; // ISO datetime
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientApikey = Omit<Apikey, "token">;
|
||||||
|
export type CreateApikey = Create<Apikey, "kid" | "token"> &
|
||||||
|
Partial<Pick<Apikey, "expires_at">>;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Abode, CreateAbode, UpdateAbode } from "./Abode.js";
|
||||||
|
import type { ClientApikey, CreateApikey } from "./Apikey.js";
|
||||||
|
import type { CreateNote, Note, PartialNote, UpdateNote } from "./Note.js";
|
||||||
|
import type { CreateResident, Resident, updateResident } from "./Resident.js";
|
||||||
|
import type {
|
||||||
|
ClientUser,
|
||||||
|
CreateUser,
|
||||||
|
LoginUser,
|
||||||
|
PartialUser,
|
||||||
|
UpdateUser,
|
||||||
|
} from "./User.js";
|
||||||
|
|
||||||
|
export interface DbInterface {
|
||||||
|
readonly: boolean;
|
||||||
|
backend: boolean;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
close(): Promise<void>;
|
||||||
|
|
||||||
|
// CRUD users
|
||||||
|
listUsers(): Promise<(PartialUser | ClientUser)[]>;
|
||||||
|
getUserById(uid: string): Promise<PartialUser | ClientUser>;
|
||||||
|
deleteUserById(uid: string): Promise<void>;
|
||||||
|
createUser(user: CreateUser): Promise<ClientUser>;
|
||||||
|
updateUser(user: UpdateUser): Promise<ClientUser>;
|
||||||
|
|
||||||
|
// get user by other properties
|
||||||
|
getUserByEmail(email: string): Promise<PartialUser | ClientUser>;
|
||||||
|
|
||||||
|
// CRUD abodes
|
||||||
|
listAbodes(): Promise<Abode[]>;
|
||||||
|
getAbodeById(aid: string): Promise<Abode>;
|
||||||
|
deleteAbodeById(aid: string): Promise<void>;
|
||||||
|
createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode>;
|
||||||
|
updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode>;
|
||||||
|
|
||||||
|
// CRUD residents
|
||||||
|
listResidents(): Promise<Resident[]>;
|
||||||
|
getResidentById(uid: string, aid: string): Promise<Resident>;
|
||||||
|
deleteResidentById(uid: string, aid: string): Promise<void>;
|
||||||
|
createResident(
|
||||||
|
resident: CreateResident,
|
||||||
|
ctx: { uid: string }
|
||||||
|
): Promise<Resident>;
|
||||||
|
updateResident(
|
||||||
|
resident: updateResident,
|
||||||
|
ctx: { uid: string }
|
||||||
|
): Promise<Resident>;
|
||||||
|
|
||||||
|
// list residents by member
|
||||||
|
listResidentsByUserId(uid: string): Promise<Resident[]>;
|
||||||
|
listResidentsByAbodeId(aid: string): Promise<Resident[]>;
|
||||||
|
|
||||||
|
// list users/abodes through residents
|
||||||
|
listUsersByAbodeId(aid: string): Promise<(PartialUser | ClientUser)[]>;
|
||||||
|
listAbodesByUserId(uid: string): Promise<Abode[]>;
|
||||||
|
|
||||||
|
// CRUD notes
|
||||||
|
listNotes(): Promise<PartialNote[]>;
|
||||||
|
getNoteById(nid: string): Promise<Note>;
|
||||||
|
deleteNoteById(nid: string): Promise<void>;
|
||||||
|
createNote(note: CreateNote, ctx: { uid: string }): Promise<Note>;
|
||||||
|
updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note>;
|
||||||
|
|
||||||
|
// list notes by access
|
||||||
|
listNotesByAbodeId(aid: string): Promise<PartialNote[]>;
|
||||||
|
listNotesByUserId(uid: string): Promise<PartialNote[]>;
|
||||||
|
|
||||||
|
// auth by session
|
||||||
|
deleteSessionsByUser(uid: string): Promise<void>;
|
||||||
|
|
||||||
|
// auth by apikey
|
||||||
|
listApikeysByUser(uid: string): Promise<ClientApikey[]>;
|
||||||
|
getApikeyById(kid: string): Promise<ClientApikey>;
|
||||||
|
createApikey(apikey: CreateApikey): Promise<[ClientApikey, `at_${string}`]>;
|
||||||
|
deleteApikeyById(kid: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendDbInterface extends DbInterface {
|
||||||
|
backend: true;
|
||||||
|
|
||||||
|
// auth by email/password
|
||||||
|
getUserByLogin(login: LoginUser): Promise<ClientUser>;
|
||||||
|
|
||||||
|
// auth by session
|
||||||
|
getUserBySession(token: `as_${string}`): Promise<ClientUser>;
|
||||||
|
createSession(uid: string): Promise<`as_${string}`>;
|
||||||
|
|
||||||
|
// auth by apikey
|
||||||
|
getUserByApikey(token: `at_${string}`): Promise<[ClientUser, ClientApikey]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackendInterface(db: DbInterface): db is BackendDbInterface {
|
||||||
|
return (
|
||||||
|
db.backend &&
|
||||||
|
(
|
||||||
|
[
|
||||||
|
"getUserByLogin",
|
||||||
|
"getUserBySession",
|
||||||
|
"createSession",
|
||||||
|
"getUserByApikey",
|
||||||
|
] as const
|
||||||
|
).every(
|
||||||
|
(x) =>
|
||||||
|
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { DbInterface } from "./DbInterface.js";
|
||||||
|
import type { Migrator } from "./Migrator.js";
|
||||||
|
|
||||||
|
export interface GetDbStatic {
|
||||||
|
name: string;
|
||||||
|
protocols: string[];
|
||||||
|
checkUrl(url: string): boolean;
|
||||||
|
getDbInterface(url: string): Promise<DbInterface>;
|
||||||
|
getMigrator(url: string): Promise<Migrator>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDbDynamic {
|
||||||
|
name: string;
|
||||||
|
protocols: string[];
|
||||||
|
getSource(url: string): Promise<GetDbStatic | null>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type AppliedMigration = {
|
||||||
|
id: number; // PK
|
||||||
|
name: string;
|
||||||
|
applied_at: string; // isodatetime
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AvailableMigration = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Migrator {
|
||||||
|
listAppliedMigrations(): Promise<AppliedMigration[]>;
|
||||||
|
listAvailableMigrations(): AvailableMigration[];
|
||||||
|
migrateTo(id: number): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Create, Update } from "./utils.js";
|
||||||
|
|
||||||
|
export type NoteType = "note";
|
||||||
|
|
||||||
|
export type NoteProperties = {
|
||||||
|
/**
|
||||||
|
* @default 'note'
|
||||||
|
*/
|
||||||
|
type?: NoteType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PartialNoteProperties = Required<Pick<NoteProperties, "type">>;
|
||||||
|
|
||||||
|
export type Note = {
|
||||||
|
nid: string; // PK, uuid
|
||||||
|
aid: string; // uuid, FK Abodes.aid
|
||||||
|
name: string;
|
||||||
|
content: string; // markdown
|
||||||
|
properties: NoteProperties;
|
||||||
|
created_at: string; // ISO datetime
|
||||||
|
created_by: string | null; // uuid, FK Users.uid
|
||||||
|
updated_at: string; // ISO datetime
|
||||||
|
updated_by: string | null; // uuid, FK Users.uid
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PartialNote = Omit<Note, "content" | "properties"> & {
|
||||||
|
properties: PartialNoteProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateNote = Create<
|
||||||
|
Omit<Note, "properties"> & { properties: PartialNoteProperties },
|
||||||
|
"nid"
|
||||||
|
>;
|
||||||
|
export type UpdateNote = Update<
|
||||||
|
Omit<Note, "properties" | "aid"> & { properties: PartialNoteProperties },
|
||||||
|
"nid"
|
||||||
|
>;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Create, Update } from "./utils.js";
|
||||||
|
|
||||||
|
export type ResidentFlags = {
|
||||||
|
admin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Resident = {
|
||||||
|
uid: string; // PK, uuid, FK User.uid
|
||||||
|
aid: string; // PK, uuid, FK Abode.aid
|
||||||
|
flags: ResidentFlags;
|
||||||
|
created_at: string; // ISO datetime
|
||||||
|
created_by: string | null; // uuid, FK Users.uid
|
||||||
|
updated_at: string; // ISO datetime
|
||||||
|
updated_by: string | null; // uuid, FK Users.uid
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateResident = Create<Resident, never>;
|
||||||
|
export type updateResident = Update<Resident, "uid" | "aid">;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type Session = {
|
||||||
|
uid: string; // uuid, FK users.uid
|
||||||
|
token: string; // PK
|
||||||
|
created_at: string; // ISO datetime
|
||||||
|
updated_at: string; // ISO datetime
|
||||||
|
expires_at: string; // ISO datetime
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Create, Update } from "./utils.js";
|
||||||
|
|
||||||
|
export type UserFlags = {
|
||||||
|
admin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
uid: string; // PK, uuid
|
||||||
|
email: string; // email
|
||||||
|
name: string;
|
||||||
|
password:
|
||||||
|
| `#${"unset"}` // special state
|
||||||
|
| `$${string}$${string}`; // hashed password
|
||||||
|
flags: UserFlags;
|
||||||
|
created_at: string; // ISO datetime
|
||||||
|
updated_at: string; // ISO datetime
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PartialUser = Omit<User, "email" | "password">;
|
||||||
|
export type ClientUser = Omit<User, "password">;
|
||||||
|
export type CreateUser = Create<User, "uid">;
|
||||||
|
export type UpdateUser = Update<User, "uid">;
|
||||||
|
|
||||||
|
export type LoginUser = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidUserPassword(
|
||||||
|
password: string
|
||||||
|
): password is User["password"] {
|
||||||
|
if (password.startsWith("#")) {
|
||||||
|
return ["unset"].includes(password.slice(1));
|
||||||
|
} else {
|
||||||
|
return !!password.match(/^\$.+\$.+$/);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class AbodeError extends Error {}
|
||||||
|
|
||||||
|
export class NotFoundAbodeError extends AbodeError {}
|
||||||
|
export class NotAuthorizedAbodeError extends AbodeError {}
|
||||||
|
export class ConflictAbodeError extends AbodeError {}
|
||||||
|
export class ReadonlyAbodeError extends AbodeError {}
|
||||||
|
export class InvalidAbodeError extends AbodeError {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type WithoutMetadata<T> = Omit<T, `${string}_at` | `${string}_by`>;
|
||||||
|
export type Update<T, K extends keyof T> = Partial<WithoutMetadata<T>> &
|
||||||
|
Pick<T, K>;
|
||||||
|
export type Create<T, K extends keyof T> = Omit<WithoutMetadata<T>, K>;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
declare const natives: {
|
||||||
|
sqlite: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare const compiledSources: string[] &
|
||||||
|
Omit<Partial<Record<string, boolean>>, keyof string[]>;
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
declare module "*.sql" {
|
||||||
|
const data: string;
|
||||||
|
export default data;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.schema.json" {
|
||||||
|
import type { Schema } from "ajv";
|
||||||
|
const schema: Schema;
|
||||||
|
export default schema;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { LoadHook } from "node:module";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
export const load: LoadHook = async (url, context, nextLoad) => {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
text: {
|
||||||
|
if (urlObj.protocol !== "file:") break text;
|
||||||
|
if (!urlObj.pathname.endsWith(".sql")) break text;
|
||||||
|
const text = await readFile(urlObj.pathname, "utf8");
|
||||||
|
return {
|
||||||
|
format: "json",
|
||||||
|
shortCircuit: true,
|
||||||
|
source: JSON.stringify(text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextLoad(url, context);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { register } from "node:module";
|
||||||
|
import { findNatives } from "../pack/natives.js";
|
||||||
|
import { existingSources } from "../pack/sources.js";
|
||||||
|
|
||||||
|
register(new URL("./loader.ts", import.meta.url));
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
natives: await findNatives(),
|
||||||
|
compiledSources: ["api", "sqlite"].sort(),
|
||||||
|
});
|
||||||
|
for (const source of existingSources)
|
||||||
|
Object.assign(compiledSources, {
|
||||||
|
[source]: compiledSources.includes(source),
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.on("message", (msg) => {
|
||||||
|
if (
|
||||||
|
msg.includes(
|
||||||
|
"A pending update was not accepted, and reached the root module:"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("[hot] Restarting due to unaccepted pending update");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("What are you doing?");
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { register } from "node:module";
|
||||||
|
|
||||||
|
register("dynohot/loader", {
|
||||||
|
parentURL: import.meta.url,
|
||||||
|
data: {
|
||||||
|
silent: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { register } from "node:module";
|
||||||
|
|
||||||
|
register("dynohot/loader", {
|
||||||
|
parentURL: import.meta.url,
|
||||||
|
data: {
|
||||||
|
ignore: /\/node_modules\/|\/schema\//, // it doesn't really seem to enjoy us loading raw schema data
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { readdir, stat } from "node:fs/promises";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
async function find(path: string, name: string): Promise<string | null> {
|
||||||
|
for (const file of await readdir(path, { withFileTypes: true })) {
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
const found = await find(join(file.parentPath, file.name), name);
|
||||||
|
if (found) return found;
|
||||||
|
} else if (file.isFile()) {
|
||||||
|
if (file.name === name) return join(file.parentPath, file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFile(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const st = await stat(path);
|
||||||
|
return st.isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPackageJsonDir(path: string): Promise<string | null> {
|
||||||
|
if (await isFile(join(path, "package.json"))) return path;
|
||||||
|
const next = dirname(path);
|
||||||
|
if (next === path) return null;
|
||||||
|
return getPackageJsonDir(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findNative(
|
||||||
|
module: string,
|
||||||
|
native: string
|
||||||
|
): Promise<string> {
|
||||||
|
const path = await getPackageJsonDir(
|
||||||
|
createRequire(import.meta.url).resolve(module)
|
||||||
|
);
|
||||||
|
if (!path) throw new Error(`Cannot find module directory for ${module}`);
|
||||||
|
const file = await find(path, native);
|
||||||
|
if (!file)
|
||||||
|
throw new Error(
|
||||||
|
`Cannot find native ${native} of package ${module} in ${path}`
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryFindNative(
|
||||||
|
module: string,
|
||||||
|
native: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await findNative(module, native);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findNatives(): Promise<typeof natives> {
|
||||||
|
return {
|
||||||
|
sqlite: await tryFindNative("better-sqlite3", "better_sqlite3.node"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const existingSources: string[] = ["api", "sqlite"];
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default async function loader({ loader }: { loader: string }) {
|
||||||
|
const { webpack } = await import(loader);
|
||||||
|
return await webpack();
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as schemas from "../../schema/schemas.js";
|
||||||
|
import { createAjv, loadSchemas } from "../../schema/ajv.js";
|
||||||
|
// @ts-expect-error no type for us apparently
|
||||||
|
import standaloneCode from "ajv/dist/standalone";
|
||||||
|
|
||||||
|
// this code generator overrides the code from @/schema/validators.ts
|
||||||
|
// runs at build time when compiling with webpack
|
||||||
|
|
||||||
|
export async function webpack(): Promise<{ code: string }> {
|
||||||
|
const validator = createAjv({
|
||||||
|
code: {
|
||||||
|
source: true,
|
||||||
|
esm: true,
|
||||||
|
// we want to optimize as much as possible and don't care how long it takes
|
||||||
|
optimize: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
loadSchemas(validator);
|
||||||
|
|
||||||
|
// this is halfway to magical because it's not typed for some reason
|
||||||
|
// it seems to output schema code but not all of it is used, and it doesn't assign .schema
|
||||||
|
let code: string = standaloneCode(
|
||||||
|
validator,
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(schemas).map(([id, schema]) => [id, schema.$id])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// assign the .schema ourselves to the validation functions
|
||||||
|
// this is potentially wasteful, some of it is already in the output but we can't access it
|
||||||
|
for (const [name, schema] of Object.entries(schemas)) {
|
||||||
|
code += `;Object.assign(${name},{schema:${JSON.stringify(schema)}})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createContext, useState, type ReactNode } from "react";
|
||||||
|
import type { DbInterface } from "../../db/types/DbInterface.js";
|
||||||
|
|
||||||
|
export const DbContext = createContext<DbInterface | null>(null);
|
||||||
|
DbContext.displayName = "DbContext";
|
||||||
|
|
||||||
|
export const SetDbContext = createContext<
|
||||||
|
((db: DbInterface | null) => void) | null
|
||||||
|
>(null);
|
||||||
|
SetDbContext.displayName = "SetDbContext";
|
||||||
|
|
||||||
|
export function DbProvider({
|
||||||
|
children,
|
||||||
|
db: initialDb = null,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
db?: DbInterface | null;
|
||||||
|
}) {
|
||||||
|
const [db, setDb] = useState(initialDb);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SetDbContext value={setDb}>
|
||||||
|
<DbContext value={db}>{children}</DbContext>
|
||||||
|
</SetDbContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ComponentType,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { idAssert } from "../../util/ts.js";
|
||||||
|
|
||||||
|
export interface PopupManagerContextData {
|
||||||
|
openPopup(popup: ComponentType<{ id: string; onClose: () => void }>): string;
|
||||||
|
openPopup<T>(
|
||||||
|
popup: ComponentType<{ id: string; onClose: () => void } & T>,
|
||||||
|
props: T
|
||||||
|
): string;
|
||||||
|
|
||||||
|
closePopup(id: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PopupManagerContext =
|
||||||
|
createContext<PopupManagerContextData | null>(null);
|
||||||
|
PopupManagerContext.displayName = "PopupManagerContext";
|
||||||
|
|
||||||
|
type Popup = {
|
||||||
|
id: string;
|
||||||
|
Component: ComponentType<{ id: string; onClose: () => void }>;
|
||||||
|
props: { id: string; onClose: () => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PopupManager({ children }: { children: ReactNode }) {
|
||||||
|
const [popups, setPopups] = useState<Popup[]>([]);
|
||||||
|
|
||||||
|
const openPopup = useCallback<PopupManagerContextData["openPopup"]>(
|
||||||
|
(
|
||||||
|
Component: ComponentType<{ id: string; onClose: () => void }>,
|
||||||
|
props = {}
|
||||||
|
) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
Object.assign(props, {
|
||||||
|
id,
|
||||||
|
onClose: () => setPopups((prev) => prev.filter((x) => x.id !== id)),
|
||||||
|
});
|
||||||
|
idAssert<{ id: string; onClose: () => void }>(props);
|
||||||
|
setPopups((prev) => [...prev, { id, Component, props }]);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const closePopup = useCallback((id: string) => {
|
||||||
|
setPopups((prev) => prev.filter((x) => x.id !== id));
|
||||||
|
}, []);
|
||||||
|
const ctx = useMemo<PopupManagerContextData>(
|
||||||
|
() => ({ openPopup, closePopup }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopupManagerContext value={ctx}>
|
||||||
|
{children}
|
||||||
|
{popups.map((popup) => (
|
||||||
|
<popup.Component key={popup.id} {...popup.props} />
|
||||||
|
))}
|
||||||
|
</PopupManagerContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { loadAbodeById, loadAllAbodes } from "../../store/loaders/abodes.js";
|
||||||
|
import { useSelector } from "../../store/react.js";
|
||||||
|
import { getAbode, getAbodes } from "../../store/slices/abodes.js";
|
||||||
|
import { useLoad } from "../useLoad.js";
|
||||||
|
|
||||||
|
export function useDataAllAbodes() {
|
||||||
|
const abodes = useSelector(getAbodes);
|
||||||
|
const status = useLoad(loadAllAbodes);
|
||||||
|
return { ...status, abodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataAbodeById(aid: string) {
|
||||||
|
const abode = useSelector((state) => getAbode(state, aid));
|
||||||
|
const status = useLoad(loadAbodeById, { aid });
|
||||||
|
return { ...status, abode };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
loadAllResidents,
|
||||||
|
loadResidentsByAbodeId,
|
||||||
|
loadResidentsByUserId,
|
||||||
|
} from "../../store/loaders/residents.js";
|
||||||
|
import { useSelector } from "../../store/react.js";
|
||||||
|
import { getResidents } from "../../store/slices/residents.js";
|
||||||
|
import { useLoad } from "../useLoad.js";
|
||||||
|
|
||||||
|
export function useDataAllResidents() {
|
||||||
|
const residents = useSelector(getResidents);
|
||||||
|
const status = useLoad(loadAllResidents);
|
||||||
|
return { ...status, residents };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataResidentsByAbodeId(aid: string) {
|
||||||
|
const allResidents = useSelector(getResidents);
|
||||||
|
const status = useLoad(loadResidentsByAbodeId, { aid });
|
||||||
|
const residents = useMemo(
|
||||||
|
() => Object.values(allResidents).filter((x) => x.aid === aid),
|
||||||
|
[allResidents, aid]
|
||||||
|
);
|
||||||
|
return { ...status, residents };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataResidentsByUserId(uid: string) {
|
||||||
|
const allResidents = useSelector(getResidents);
|
||||||
|
const status = useLoad(loadResidentsByUserId, { uid });
|
||||||
|
const residents = useMemo(
|
||||||
|
() => Object.values(allResidents).filter((x) => x.uid === uid),
|
||||||
|
[allResidents, uid]
|
||||||
|
);
|
||||||
|
return { ...status, residents };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
loadAllUsers,
|
||||||
|
loadUserByEmail,
|
||||||
|
loadUserById,
|
||||||
|
} from "../../store/loaders/users.js";
|
||||||
|
import { useSelector } from "../../store/react.js";
|
||||||
|
import { getUser, getUserByEmail, getUsers } from "../../store/slices/users.js";
|
||||||
|
import { useLoad } from "../useLoad.js";
|
||||||
|
|
||||||
|
export function useDataAllUsers() {
|
||||||
|
const users = useSelector(getUsers);
|
||||||
|
const status = useLoad(loadAllUsers);
|
||||||
|
return { ...status, users };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataUserById(uid: string) {
|
||||||
|
const user = useSelector((state) => getUser(state, uid));
|
||||||
|
const status = useLoad(loadUserById, { uid });
|
||||||
|
return { ...status, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataUserByEmail(email: string) {
|
||||||
|
const user = useSelector((state) => getUserByEmail(state, email));
|
||||||
|
const status = useLoad(loadUserByEmail, { email });
|
||||||
|
return { ...status, user };
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { use, useCallback } from "react";
|
||||||
|
import { DbContext } from "../contexts/Db.js";
|
||||||
|
import type { DbInterface } from "../../db/types/DbInterface.js";
|
||||||
|
import type { Store } from "../store/store.js";
|
||||||
|
import { useStore } from "../store/react.js";
|
||||||
|
|
||||||
|
export function useAction<P extends any[], R>(
|
||||||
|
action: (...params: [...P, { db: DbInterface; store: Store }]) => Promise<R>
|
||||||
|
): (...args: P) => Promise<R> {
|
||||||
|
const db = use(DbContext);
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
async (...params: P) => {
|
||||||
|
if (!db) throw new Error("DB not present");
|
||||||
|
return action(...params, { db, store });
|
||||||
|
},
|
||||||
|
[action, db]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { use, useCallback, useEffect } from "react";
|
||||||
|
import { load, type Loader } from "../store/load.js";
|
||||||
|
import { useSelector, useStore } from "../store/react.js";
|
||||||
|
import { getLoading, type LoadingState } from "../store/slices/loading.js";
|
||||||
|
import { DbContext } from "../contexts/Db.js";
|
||||||
|
|
||||||
|
function selectTrue() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseLoadResult = (LoadingState | { status: "pending" }) & {
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLoad<P>(loader: Loader<P>, params: P): UseLoadResult;
|
||||||
|
export function useLoad(loader: Loader<void>): UseLoadResult;
|
||||||
|
export function useLoad<P>(loader: Loader<P>, params?: P): UseLoadResult {
|
||||||
|
const id = loader.id(params!);
|
||||||
|
const state = useSelector((state) => getLoading(state, id));
|
||||||
|
|
||||||
|
const db = use(DbContext);
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const condition = loader.condition ?? selectTrue;
|
||||||
|
const meetsCondition = useSelector((state) => condition(params!, state));
|
||||||
|
const meetsDb = !!db || !loader.requiresDb;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meetsCondition || !meetsDb) return;
|
||||||
|
void load({ loader, params: params!, store, db });
|
||||||
|
}, [loader, params, db, meetsCondition, meetsDb]);
|
||||||
|
|
||||||
|
const refresh = useCallback(
|
||||||
|
() => load({ loader, params: params!, store, db, refresh: true }),
|
||||||
|
[loader, params, db]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(state ?? { status: "pending" }),
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { CreateAbode, UpdateAbode } from "../../../db/types/Abode.js";
|
||||||
|
import type { DbInterface } from "../../../db/types/DbInterface.js";
|
||||||
|
import { waitForLoadIfLoading } from "../load.js";
|
||||||
|
import { delAbode, getAbode, setAbode } from "../slices/abodes.js";
|
||||||
|
import { clearLoading } from "../slices/loading.js";
|
||||||
|
import { getLoginUser } from "../slices/login.js";
|
||||||
|
import type { Store } from "../store.js";
|
||||||
|
|
||||||
|
export async function deleteAbodeById(
|
||||||
|
aid: string,
|
||||||
|
{ store, db }: { store: Store; db: DbInterface }
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
waitForLoadIfLoading(store, "loadAllAbodes"),
|
||||||
|
waitForLoadIfLoading(store, `loadAbodeById:${aid}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.deleteAbodeById(aid);
|
||||||
|
store.dispatch(delAbode(aid));
|
||||||
|
store.dispatch(clearLoading(`loadAbodeById:${aid}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAbode(
|
||||||
|
abode: UpdateAbode,
|
||||||
|
{ store, db }: { store: Store; db: DbInterface }
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
waitForLoadIfLoading(store, "loadAllAbodes"),
|
||||||
|
waitForLoadIfLoading(store, `loadAbodeById:${abode.aid}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const user = getLoginUser(store.getState());
|
||||||
|
if (!user) throw new Error("Not logged in");
|
||||||
|
const next = await db.updateAbode(abode, { uid: user.uid });
|
||||||
|
store.dispatch(setAbode(next));
|
||||||
|
store.dispatch(clearLoading(`loadAbodeById:${abode.aid}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAbode(
|
||||||
|
abode: CreateAbode,
|
||||||
|
{ store, db }: { store: Store; db: DbInterface }
|
||||||
|
): Promise<string> {
|
||||||
|
await waitForLoadIfLoading(store, "loadAllAbodes");
|
||||||
|
|
||||||
|
const user = getLoginUser(store.getState());
|
||||||
|
if (!user) throw new Error("Not logged in");
|
||||||
|
const next = await db.createAbode(abode, { uid: user.uid });
|
||||||
|
store.dispatch(setAbode(next));
|
||||||
|
return next.aid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
export const clearAll = createAction("store/clear");
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { DbInterface } from "../../../db/types/DbInterface.js";
|
||||||
|
import type { CreateUser, UpdateUser } from "../../../db/types/User.js";
|
||||||
|
import { waitForLoadIfLoading } from "../load.js";
|
||||||
|
import { clearLoading } from "../slices/loading.js";
|
||||||
|
import { delUser, getUser, setUser } from "../slices/users.js";
|
||||||
|
import type { Store } from "../store.js";
|
||||||
|
|
||||||
|
export async function deleteUserById(
|
||||||
|
uid: string,
|
||||||
|
{ store, db }: { store: Store; db: DbInterface }
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
waitForLoadIfLoading(store, "loadAllUsers"),
|
||||||
|
waitForLoadIfLoading(store, `loadUserById:${uid}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const user = getUser(store.getState(), uid);
|
||||||
|
await db.deleteUserById(uid);
|
||||||
|
store.dispatch(delUser(uid));
|
||||||
|
store.dispatch(clearLoading(`loadUserById:${uid}`));
|
||||||
|
if (user && "email" in user)
|
||||||
|
store.dispatch(clearLoading(`loadUserByEmail:${user.email}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
user: UpdateUser,
|
||||||
|
{ store, db }: { store: Store; db: DbInterface }
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
waitForLoadIfLoading(store, "loadAllUsers"),
|
||||||
|
waitForLoadIfLoading(store, `loadUserById:${user.uid}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const current = getUser(store.getState(), user.uid);
|
||||||
|
const next = await db.updateUser(user);
|
||||||
|
store.dispatch(setUser(next));
|
||||||
|
store.dispatch(clearLoading(`loadUserById:${user.uid}`));
|
||||||
|
if (current && "email" in current)
|
||||||
|
store.dispatch(clearLoading(`loadUserByEmail:${current.email}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(
|
||||||
|
user: CreateUser,
|
||||||
|
{ store, db }: { store: Store; db: DbInterface }
|
||||||
|
): Promise<string> {
|
||||||
|
await waitForLoadIfLoading(store, "loadAllUsers");
|
||||||
|
|
||||||
|
const next = await db.createUser(user);
|
||||||
|
store.dispatch(setUser(next));
|
||||||
|
return next.uid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { DbInterface } from "../../db/types/DbInterface.js";
|
||||||
|
import { objectError } from "../../util/error.js";
|
||||||
|
import { getLoading, getLoadingStatus, setLoading } from "./slices/loading.js";
|
||||||
|
import type { Action, State, Store } from "./store.js";
|
||||||
|
import { waitFor } from "./utils.js";
|
||||||
|
|
||||||
|
export type LoadApi = {
|
||||||
|
signal: AbortSignal;
|
||||||
|
store: Store;
|
||||||
|
db: DbInterface | null;
|
||||||
|
waitFor: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Loader<P> = {
|
||||||
|
type: string;
|
||||||
|
id: (params: P) => string;
|
||||||
|
load: (params: P, api: LoadApi) => Promise<void | null | Action | Action[]>;
|
||||||
|
requiresDb?: boolean;
|
||||||
|
condition?: (params: P, state: State) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadImpl<P>({
|
||||||
|
loader,
|
||||||
|
params,
|
||||||
|
store,
|
||||||
|
db,
|
||||||
|
refresh = false,
|
||||||
|
}: {
|
||||||
|
loader: Loader<P>;
|
||||||
|
params: P;
|
||||||
|
store: Store;
|
||||||
|
db: DbInterface | null;
|
||||||
|
refresh?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const id = loader.id(params);
|
||||||
|
const { type } = loader;
|
||||||
|
const state = store.getState();
|
||||||
|
const status = getLoadingStatus(state, id);
|
||||||
|
if (status === "loaded" && !refresh) return;
|
||||||
|
if (status === "loading") {
|
||||||
|
return waitFor(store, (state) => {
|
||||||
|
const status = getLoadingStatus(state, id);
|
||||||
|
return status === "loaded" || status === "error";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch(
|
||||||
|
setLoading([
|
||||||
|
id,
|
||||||
|
{ status: refresh ? "refreshing" : "loading", type, params },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
if (loader.requiresDb && !db)
|
||||||
|
throw new Error("Loader requires DB and it was not ready");
|
||||||
|
if (loader.condition && !loader.condition(params, state))
|
||||||
|
throw new Error("Loader has an unmet condition");
|
||||||
|
|
||||||
|
const result = await loader.load(params, {
|
||||||
|
db,
|
||||||
|
store,
|
||||||
|
signal: controller.signal,
|
||||||
|
waitFor: (id) =>
|
||||||
|
waitForLoadIfLoading(store, id, { signal: controller.signal }),
|
||||||
|
});
|
||||||
|
const actions = Array.isArray(result) ? result : result ? [result] : [];
|
||||||
|
for (const action of actions) store.dispatch(action);
|
||||||
|
store.dispatch(setLoading([id, { status: "loaded", type, params }]));
|
||||||
|
} catch (e) {
|
||||||
|
store.dispatch(
|
||||||
|
setLoading([id, { status: "error", type, params, error: objectError(e) }])
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loaders = new Map<string, Loader<any>>();
|
||||||
|
|
||||||
|
export function load<P>(props: {
|
||||||
|
loader: Loader<P>;
|
||||||
|
params: P;
|
||||||
|
store: Store;
|
||||||
|
db: DbInterface | null;
|
||||||
|
refresh?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { loader } = props;
|
||||||
|
if (!loaders.has(loader.type)) loaders.set(loader.type, loader);
|
||||||
|
return loadImpl(props).catch(() => void 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh({
|
||||||
|
id,
|
||||||
|
store,
|
||||||
|
db,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
store: Store;
|
||||||
|
db: DbInterface | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const state = getLoading(store.getState(), id);
|
||||||
|
if (state?.status !== "loaded" && state?.status !== "error")
|
||||||
|
throw new Error(`Cannot refresh while in ${state?.status ?? "none"} state`);
|
||||||
|
const loader = loaders.get(state.type);
|
||||||
|
if (!loader)
|
||||||
|
throw new Error(`Loader ${state.type} not known, cannot refresh`);
|
||||||
|
return loadImpl({ loader, params: state.params, store, db, refresh: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loader<P>(loader: Loader<P>): Loader<P> {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForLoadIfLoading(
|
||||||
|
store: Store,
|
||||||
|
id: string,
|
||||||
|
{ signal }: { signal?: AbortSignal } = {}
|
||||||
|
) {
|
||||||
|
if (!getLoadingStatus(store.getState(), id)) return;
|
||||||
|
return waitFor(
|
||||||
|
store,
|
||||||
|
(state) => {
|
||||||
|
const status = getLoadingStatus(state, id);
|
||||||
|
return status === "loaded" || status === "error";
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { loader } from "../load.js";
|
||||||
|
import { addAbodes, getAbode, setAbode } from "../slices/abodes.js";
|
||||||
|
|
||||||
|
export const loadAllAbodes = loader<void>({
|
||||||
|
type: "loadAllAbodes",
|
||||||
|
id: () => "loadAllAbodes",
|
||||||
|
requiresDb: true,
|
||||||
|
async load(_, { db }) {
|
||||||
|
const abodes = await db!.listAbodes();
|
||||||
|
return addAbodes(abodes);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadAbodeById = loader({
|
||||||
|
type: "loadAbodeById",
|
||||||
|
id: ({ aid }: { aid: string }) => `loadAbodeById:${aid}`,
|
||||||
|
requiresDb: true,
|
||||||
|
async load({ aid }, { db, store, waitFor }) {
|
||||||
|
await waitFor("loadAllAbodes");
|
||||||
|
if (getAbode(store.getState(), aid)) return;
|
||||||
|
const abode = await db!.getAbodeById(aid);
|
||||||
|
return setAbode(abode);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { loader } from "../load.js";
|
||||||
|
import { getLoadingStatus } from "../slices/loading.js";
|
||||||
|
import { addResidents } from "../slices/residents.js";
|
||||||
|
|
||||||
|
export const loadAllResidents = loader<void>({
|
||||||
|
type: "loadAllResidents",
|
||||||
|
id: () => "loadAllResidents",
|
||||||
|
requiresDb: true,
|
||||||
|
async load(_, { db }) {
|
||||||
|
const residents = await db!.listResidents();
|
||||||
|
return addResidents(residents);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadResidentsByUserId = loader<{ uid: string }>({
|
||||||
|
type: "loadResidentsByUserId",
|
||||||
|
id: ({ uid }) => `loadResidentsByUserId:${uid}`,
|
||||||
|
requiresDb: true,
|
||||||
|
async load({ uid }, { db, store, waitFor }) {
|
||||||
|
await waitFor("loadAllResidents");
|
||||||
|
if (getLoadingStatus(store.getState(), "loadAllResidents") === "loaded")
|
||||||
|
return;
|
||||||
|
const residents = await db!.listResidentsByUserId(uid);
|
||||||
|
return addResidents(residents);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadResidentsByAbodeId = loader<{ aid: string }>({
|
||||||
|
type: "loadResidentsByAbodeId",
|
||||||
|
id: ({ aid }) => `loadResidentsByAbodeId:${aid}`,
|
||||||
|
requiresDb: true,
|
||||||
|
async load({ aid }, { db, store, waitFor }) {
|
||||||
|
await waitFor("loadAllResidents");
|
||||||
|
if (getLoadingStatus(store.getState(), "loadAllResidents") === "loaded")
|
||||||
|
return;
|
||||||
|
const residents = await db!.listResidentsByAbodeId(aid);
|
||||||
|
return addResidents(residents);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { loader } from "../load.js";
|
||||||
|
import { addUsers, getUser, getUsers, setUser } from "../slices/users.js";
|
||||||
|
|
||||||
|
export const loadAllUsers = loader<void>({
|
||||||
|
type: "loadAllUsers",
|
||||||
|
id: () => "loadAllUsers",
|
||||||
|
requiresDb: true,
|
||||||
|
async load(_, { db }) {
|
||||||
|
const users = await db!.listUsers();
|
||||||
|
return addUsers(users);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadUserById = loader({
|
||||||
|
type: "loadUserById",
|
||||||
|
id: ({ uid }: { uid: string }) => `loadUserById:${uid}`,
|
||||||
|
requiresDb: true,
|
||||||
|
async load({ uid }, { db, store, waitFor }) {
|
||||||
|
await waitFor("loadAllUsers");
|
||||||
|
if (getUser(store.getState(), uid)) return;
|
||||||
|
const user = await db!.getUserById(uid);
|
||||||
|
return setUser(user);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadUserByEmail = loader({
|
||||||
|
type: "loadUserByEmail",
|
||||||
|
id: ({ email }: { email: string }) => `loadUserByEmail:${email}`,
|
||||||
|
requiresDb: true,
|
||||||
|
async load({ email }, { db, store, waitFor }) {
|
||||||
|
await waitFor("loadAllUsers");
|
||||||
|
const users = getUsers(store.getState());
|
||||||
|
if (Object.values(users).some((x) => "email" in x && x.email === email))
|
||||||
|
return;
|
||||||
|
const user = await db!.getUserByEmail(email);
|
||||||
|
return setUser({ email, ...user });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
createDispatchHook,
|
||||||
|
createSelectorHook,
|
||||||
|
createStoreHook,
|
||||||
|
Provider as RawProvider,
|
||||||
|
type ProviderProps,
|
||||||
|
type ReactReduxContextValue,
|
||||||
|
} from "react-redux";
|
||||||
|
import type { Store, State } from "./store.js";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
const AbodeStoreContext = createContext<ReactReduxContextValue | null>(null);
|
||||||
|
AbodeStoreContext.displayName = "AbodeStoreContext";
|
||||||
|
|
||||||
|
export function Provider(
|
||||||
|
props: Omit<ProviderProps, "context" | "store" | "serverState"> & {
|
||||||
|
store: Store;
|
||||||
|
serverState?: State;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return <RawProvider context={AbodeStoreContext} {...props} />;
|
||||||
|
}
|
||||||
|
Object.assign(Provider, { displayName: "AbodeStoreProvider" });
|
||||||
|
export const useSelector =
|
||||||
|
createSelectorHook(AbodeStoreContext).withTypes<State>();
|
||||||
|
export const useStore = createStoreHook(AbodeStoreContext).withTypes<Store>();
|
||||||
|
export const useDispatch =
|
||||||
|
createDispatchHook(AbodeStoreContext).withTypes<Store["dispatch"]>();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
createEntityAdapter,
|
||||||
|
createSlice,
|
||||||
|
type WithSlice,
|
||||||
|
} from "@reduxjs/toolkit";
|
||||||
|
import { reducer } from "../store.js";
|
||||||
|
import { clearAll } from "../actions/clearAll.js";
|
||||||
|
import type { Abode } from "../../../db/types/Abode.js";
|
||||||
|
|
||||||
|
const abodesAdapter = createEntityAdapter({
|
||||||
|
selectId: (abode: Abode) => abode.aid,
|
||||||
|
});
|
||||||
|
const abodesSelectors = abodesAdapter.getSelectors();
|
||||||
|
|
||||||
|
const abodesSlice = createSlice({
|
||||||
|
name: "abodes",
|
||||||
|
initialState: abodesAdapter.getInitialState(),
|
||||||
|
selectors: {
|
||||||
|
getAbode: abodesSelectors.selectById,
|
||||||
|
getAbodes: abodesSelectors.selectEntities,
|
||||||
|
getAbodeIds: abodesSelectors.selectIds,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
setAbode: abodesAdapter.setOne,
|
||||||
|
addAbodes: abodesAdapter.addMany,
|
||||||
|
delAbode: abodesAdapter.removeOne,
|
||||||
|
clearAbodes: abodesAdapter.removeAll,
|
||||||
|
},
|
||||||
|
extraReducers(builder) {
|
||||||
|
builder.addCase(clearAll, () => abodesAdapter.getInitialState());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "../store.js" {
|
||||||
|
export interface LazySlices extends WithSlice<typeof abodesSlice> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
selectors: { getAbode, getAbodes, getAbodeIds },
|
||||||
|
actions: { setAbode, addAbodes, delAbode, clearAbodes },
|
||||||
|
selectSlice: selectAbodes,
|
||||||
|
} = abodesSlice.injectInto(reducer);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
createSlice,
|
||||||
|
type PayloadAction,
|
||||||
|
type WithSlice,
|
||||||
|
} from "@reduxjs/toolkit";
|
||||||
|
import { clearAll } from "../actions/clearAll.js";
|
||||||
|
import { reducer } from "../store.js";
|
||||||
|
import type { ObjectError } from "../../../util/error.js";
|
||||||
|
|
||||||
|
export type LoadingSlice = Record<string, LoadingState>;
|
||||||
|
export type LoadingState = (
|
||||||
|
| {
|
||||||
|
status: "loading";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "refreshing";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "error";
|
||||||
|
error: ObjectError;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "loaded";
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
type: string;
|
||||||
|
params: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialLoadingSlice: LoadingSlice = {};
|
||||||
|
|
||||||
|
const loadingSlice = createSlice({
|
||||||
|
name: "loading",
|
||||||
|
initialState: initialLoadingSlice,
|
||||||
|
selectors: {
|
||||||
|
getLoading: (state, id: string): LoadingState | null => state[id] ?? null,
|
||||||
|
getLoadingStatus: (state, id: string): LoadingState["status"] | null =>
|
||||||
|
state[id]?.status ?? null,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
setLoading: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<[id: string, state: LoadingState]>
|
||||||
|
) => {
|
||||||
|
state[action.payload[0]] = action.payload[1];
|
||||||
|
},
|
||||||
|
clearLoading: (state, action: PayloadAction<string>) => {
|
||||||
|
delete state[action.payload];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers(builder) {
|
||||||
|
builder.addCase(clearAll, () => initialLoadingSlice);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "../store.js" {
|
||||||
|
export interface LazySlices extends WithSlice<typeof loadingSlice> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
selectors: { getLoading, getLoadingStatus },
|
||||||
|
actions: { setLoading, clearLoading },
|
||||||
|
selectSlice: selectLoading,
|
||||||
|
} = loadingSlice.injectInto(reducer);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
createSlice,
|
||||||
|
type PayloadAction,
|
||||||
|
type WithSlice,
|
||||||
|
} from "@reduxjs/toolkit";
|
||||||
|
import type { ClientUser } from "../../../db/types/User.js";
|
||||||
|
import { clearAll } from "../actions/clearAll.js";
|
||||||
|
import { reducer } from "../store.js";
|
||||||
|
|
||||||
|
export type LoginSlice = {
|
||||||
|
user?: ClientUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialLoginSlice: LoginSlice = {};
|
||||||
|
|
||||||
|
const loginSlice = createSlice({
|
||||||
|
name: "login",
|
||||||
|
initialState: initialLoginSlice,
|
||||||
|
selectors: {
|
||||||
|
getLoginUser: (state) => state.user,
|
||||||
|
isLoggedIn: (state) => !!state.user,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
setLoginUser: (state, action: PayloadAction<ClientUser>) => {
|
||||||
|
state.user = action.payload;
|
||||||
|
},
|
||||||
|
logOut: (state) => {
|
||||||
|
delete state.user;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers(builder) {
|
||||||
|
builder.addCase(clearAll, () => initialLoginSlice);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "../store.js" {
|
||||||
|
export interface LazySlices extends WithSlice<typeof loginSlice> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
selectors: { getLoginUser, isLoggedIn },
|
||||||
|
actions: { setLoginUser, logOut },
|
||||||
|
} = loginSlice.injectInto(reducer);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
createEntityAdapter,
|
||||||
|
createSlice,
|
||||||
|
type WithSlice,
|
||||||
|
} from "@reduxjs/toolkit";
|
||||||
|
import { reducer } from "../store.js";
|
||||||
|
import { clearAll } from "../actions/clearAll.js";
|
||||||
|
import type { Resident } from "../../../db/types/Resident.js";
|
||||||
|
|
||||||
|
const residentsAdapter = createEntityAdapter({
|
||||||
|
selectId: (resident: Resident) => resident.uid + "/" + resident.aid,
|
||||||
|
});
|
||||||
|
const residentsSelectors = residentsAdapter.getSelectors();
|
||||||
|
|
||||||
|
const residentsSlice = createSlice({
|
||||||
|
name: "residents",
|
||||||
|
initialState: residentsAdapter.getInitialState(),
|
||||||
|
selectors: {
|
||||||
|
getResident: (state, uid: string, aid: string) =>
|
||||||
|
residentsSelectors.selectById(state, uid + "/" + aid),
|
||||||
|
getResidents: residentsSelectors.selectEntities,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
setResident: residentsAdapter.setOne,
|
||||||
|
addResidents: residentsAdapter.addMany,
|
||||||
|
delResident: residentsAdapter.removeOne,
|
||||||
|
clearResidents: residentsAdapter.removeAll,
|
||||||
|
},
|
||||||
|
extraReducers(builder) {
|
||||||
|
builder.addCase(clearAll, () => residentsAdapter.getInitialState());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "../store.js" {
|
||||||
|
export interface LazySlices extends WithSlice<typeof residentsSlice> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
selectors: { getResident, getResidents },
|
||||||
|
actions: { setResident, addResidents, delResident, clearResidents },
|
||||||
|
selectSlice: selectResidents,
|
||||||
|
} = residentsSlice.injectInto(reducer);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
createEntityAdapter,
|
||||||
|
createSlice,
|
||||||
|
type WithSlice,
|
||||||
|
} from "@reduxjs/toolkit";
|
||||||
|
import type { ClientUser, PartialUser } from "../../../db/types/User.js";
|
||||||
|
import { reducer } from "../store.js";
|
||||||
|
import { clearAll } from "../actions/clearAll.js";
|
||||||
|
|
||||||
|
const usersAdapter = createEntityAdapter({
|
||||||
|
selectId: (user: ClientUser | PartialUser) => user.uid,
|
||||||
|
});
|
||||||
|
const usersSelectors = usersAdapter.getSelectors();
|
||||||
|
|
||||||
|
const usersSlice = createSlice({
|
||||||
|
name: "users",
|
||||||
|
initialState: usersAdapter.getInitialState(),
|
||||||
|
selectors: {
|
||||||
|
getUser: usersSelectors.selectById,
|
||||||
|
getUserByEmail: (state, email: string) =>
|
||||||
|
Object.values(state.entities).find(
|
||||||
|
(x): x is ClientUser => "email" in x && x.email === email
|
||||||
|
),
|
||||||
|
getUsers: usersSelectors.selectEntities,
|
||||||
|
getUserIds: usersSelectors.selectIds,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
setUser: usersAdapter.setOne,
|
||||||
|
addUsers: usersAdapter.addMany,
|
||||||
|
delUser: usersAdapter.removeOne,
|
||||||
|
clearUsers: usersAdapter.removeAll,
|
||||||
|
},
|
||||||
|
extraReducers(builder) {
|
||||||
|
builder.addCase(clearAll, () => usersAdapter.getInitialState());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "../store.js" {
|
||||||
|
export interface LazySlices extends WithSlice<typeof usersSlice> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
selectors: { getUser, getUserByEmail, getUsers, getUserIds },
|
||||||
|
actions: { setUser, addUsers, delUser, clearUsers },
|
||||||
|
selectSlice: selectUsers,
|
||||||
|
} = usersSlice.injectInto(reducer);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { combineSlices, configureStore } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
export interface LazySlices {}
|
||||||
|
|
||||||
|
export const reducer = combineSlices().withLazyLoadedSlices<LazySlices>();
|
||||||
|
export type State = ReturnType<typeof reducer>;
|
||||||
|
|
||||||
|
export function createStore({
|
||||||
|
preloadedState,
|
||||||
|
}: { preloadedState?: State } = {}) {
|
||||||
|
return configureStore({ reducer, preloadedState, devTools: true });
|
||||||
|
}
|
||||||
|
export type Store = ReturnType<typeof createStore>;
|
||||||
|
export type Dispatch = Store["dispatch"];
|
||||||
|
export type Action = Parameters<Dispatch>[0];
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { State, Store } from "./store.js";
|
||||||
|
|
||||||
|
export async function waitFor(
|
||||||
|
store: Store,
|
||||||
|
cond: (state: State) => boolean,
|
||||||
|
{ signal }: { signal?: AbortSignal } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise<void>((ok, ko) => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.signal.addEventListener("abort", () => {
|
||||||
|
ko(new Error("Aborted waitFor"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const state = store.getState();
|
||||||
|
if (!cond(state)) return false;
|
||||||
|
ok();
|
||||||
|
controller.abort();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (check()) return;
|
||||||
|
controller.signal.addEventListener("abort", store.subscribe(check));
|
||||||
|
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
controller.abort();
|
||||||
|
},
|
||||||
|
{ signal: controller.signal }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "Abode",
|
||||||
|
"description": "Abode object",
|
||||||
|
"$id": "https://abode.codi.moe/schema/abode.schema.json",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"aid",
|
||||||
|
"name",
|
||||||
|
"created_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_at",
|
||||||
|
"updated_by"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"aid": {
|
||||||
|
"description": "Abode ID (UUID)",
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Abode name",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"description": "Abode creation date (ISO 8601 datetime with TZ)",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"description": "Abode creating user (UUID)",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"description": "Abode modification date (ISO 8601 datetime with TZ)",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"updated_by": {
|
||||||
|
"description": "Abode modifying user (UUID)",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "Create<Abode>",
|
||||||
|
"description": "Create Abode object",
|
||||||
|
"$id": "https://abode.codi.moe/schema/createabode.schema.json",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"description": "Abode name",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "Update<Abode>",
|
||||||
|
"description": "Update Abode object",
|
||||||
|
"$id": "https://abode.codi.moe/schema/updateabode.schema.json",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["aid"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"aid": {
|
||||||
|
"description": "Abode ID (UUID)",
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Abode name",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Ajv } from "ajv";
|
||||||
|
import ajvFormats from "ajv-formats";
|
||||||
|
import * as schemas from "./schemas.js";
|
||||||
|
|
||||||
|
export function createAjv(options: ConstructorParameters<typeof Ajv>[0]): Ajv {
|
||||||
|
const ajv = new Ajv({ strict: true, ...options });
|
||||||
|
(ajvFormats as unknown as (ajv: Ajv) => void)(ajv);
|
||||||
|
return ajv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSchemas(ajv: Ajv): void {
|
||||||
|
for (const schema of Object.values(schemas)) {
|
||||||
|
ajv.addSchema(schema);
|
||||||
|
}
|
||||||
|
for (const schema of Object.values(schemas)) {
|
||||||
|
ajv.validateSchema(schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "ApikeyPermissions",
|
||||||
|
"description": "Apikey permissions",
|
||||||
|
"$id": "https://abode.codi.moe/schema/apikeypermissions.schema.json",
|
||||||
|
"type": "object",
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"admin": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"enum": ["r", "rw"]
|
||||||
|
},
|
||||||
|
"residents": {
|
||||||
|
"enum": ["r", "rw"]
|
||||||
|
},
|
||||||
|
"abodes": {
|
||||||
|
"enum": ["r", "rw"]
|
||||||
|
},
|
||||||
|
"restrict_users": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"restrict_abodes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "Create<Apikey>",
|
||||||
|
"description": "Create Apikey",
|
||||||
|
"$id": "https://abode.codi.moe/schema/createapikey.schema.json",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["uid", "name", "permissions"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"uid": {
|
||||||
|
"description": "User ID (UUID)",
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Apikey name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "Apikey permissions",
|
||||||
|
"$ref": "https://abode.codi.moe/schema/apikeypermissions.schema.json"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"description": "Apikey expiry",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "CreateNote",
|
||||||
|
"description": "Create Note object",
|
||||||
|
"$id": "https://abode.codi.moe/schema/createnote.schema.json",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["aid", "name", "content", "properties"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"aid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Abode ID (uuid)",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Note name"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Note content (Markdown)"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"$ref": "https://abode.codi.moe/schema/partialnoteproperties.schema.json",
|
||||||
|
"description": "Note properties"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
|
"title": "PartialNoteProperties",
|
||||||
|
"description": "Partial Note properties",
|
||||||
|
"$id": "https://abode.codi.moe/schema/partialnoteproperties.schema.json",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"enum": ["note"],
|
||||||
|
"description": "Note type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user