ci: add pull request quality gates #12

Merged
codinget merged 1 commits from t3code/issue-four-ci into master 2026-07-23 00:22:44 +02:00
80 changed files with 2007 additions and 398 deletions
+9
View File
@@ -0,0 +1,9 @@
name: Setup
description: Set up the Node version required by this repository. Requires actions/checkout to have already run.
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
+124
View File
@@ -0,0 +1,124 @@
name: CI
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
install-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- name: Clean install dependencies
run: npm ci
- name: Production build
run: npm run build
- name: Archive dependencies
run: tar -czf node_modules.tar.gz node_modules
- uses: actions/upload-artifact@v3
with:
name: node_modules-${{ github.run_id }}
path: node_modules.tar.gz
- name: Archive build artifacts
run: tar -czf build-artifacts.tar.gz dist
- uses: actions/upload-artifact@v3
with:
name: build-artifacts-${{ github.run_id }}
path: build-artifacts.tar.gz
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- name: Clean install dependencies
run: npm ci
- name: Lint
run: npm run lint
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- name: Clean install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
typecheck-source:
runs-on: ubuntu-latest
needs: install-and-build
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- uses: actions/download-artifact@v3
with:
name: node_modules-${{ github.run_id }}
- uses: actions/download-artifact@v3
with:
name: build-artifacts-${{ github.run_id }}
- name: Restore dependencies and build artifacts
run: |
tar -xzf node_modules.tar.gz
tar -xzf build-artifacts.tar.gz
- name: Typecheck source
run: npm run typecheck
typecheck-tests:
runs-on: ubuntu-latest
needs: install-and-build
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- uses: actions/download-artifact@v3
with:
name: node_modules-${{ github.run_id }}
- name: Restore dependencies
run: tar -xzf node_modules.tar.gz
- name: Typecheck tests
run: npm run typecheck:test
test:
runs-on: ubuntu-latest
needs: install-and-build
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- uses: actions/download-artifact@v3
with:
name: node_modules-${{ github.run_id }}
- name: Restore dependencies
run: tar -xzf node_modules.tar.gz
- name: Run full test suite
run: npm test
+1
View File
@@ -0,0 +1 @@
22
+2
View File
@@ -0,0 +1,2 @@
dist
node_modules
+22
View File
@@ -0,0 +1,22 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**"],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
"no-control-regex": "off",
"no-empty": "off",
"no-fallthrough": "off",
"prefer-const": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
"@typescript-eslint/no-unused-vars": "off",
},
},
);
+1303 -20
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -25,7 +25,9 @@
"test:shared": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name 'index.test.ts' | sort)", "test:shared": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name 'index.test.ts' | sort)",
"test:tools": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/tools -name '*.test.ts' | sort)", "test:tools": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/tools -name '*.test.ts' | sort)",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"typecheck:test": "tsc --noEmit -p tsconfig.test.json" "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
"lint": "eslint .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@koa/bodyparser": "^6.0.0", "@koa/bodyparser": "^6.0.0",
@@ -44,21 +46,25 @@
"react-redux": "^9.2.0" "react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/pg": "^8.20.0",
"@types/koa": "^3.0.0", "@types/koa": "^3.0.0",
"@types/koa__router": "^12.0.4", "@types/koa__router": "^12.0.4",
"@types/pg": "^8.20.0",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/webpack-bundle-analyzer": "^4.7.0", "@types/webpack-bundle-analyzer": "^4.7.0",
"copy-webpack-plugin": "^13.0.1", "copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"dynohot": "^2.1.1", "dynohot": "^2.1.1",
"eslint": "^9.39.5",
"mini-css-extract-plugin": "^2.9.4", "mini-css-extract-plugin": "^2.9.4",
"prettier": "^3.6.2",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"scss-loader": "^0.0.1", "scss-loader": "^0.0.1",
"ts-loader": "^9.5.4", "ts-loader": "^9.5.4",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^8.65.0",
"val-loader": "^6.0.0", "val-loader": "^6.0.0",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
+3 -3
View File
@@ -40,7 +40,7 @@ switch (cmd) {
if (!current.length) console.log("(none)"); if (!current.length) console.log("(none)");
for (const migration of current) { for (const migration of current) {
console.log( console.log(
`- ${migration.id} (${migration.name}) applied at ${migration.applied_at}` `- ${migration.id} (${migration.name}) applied at ${migration.applied_at}`,
); );
} }
break; break;
@@ -73,7 +73,7 @@ switch (cmd) {
password: await hashPassword("changeme"), password: await hashPassword("changeme"),
}); });
console.log( console.log(
`Created user 'admin@codi.moe' (${uid}) with password 'changeme' and admin flag` `Created user 'admin@codi.moe' (${uid}) with password 'changeme' and admin flag`,
); );
} }
users = await db.listUsers(); users = await db.listUsers();
@@ -88,7 +88,7 @@ switch (cmd) {
expires_at: null, expires_at: null,
}); });
console.log( console.log(
`Created apikey '${token}' (${apikey.kid}) with permissions admin, all and no expiry` `Created apikey '${token}' (${apikey.kid}) with permissions admin, all and no expiry`,
); );
} }
} }
+4 -4
View File
@@ -21,7 +21,7 @@ if (args.length > 1) printUsage("too many arguments");
console.log( console.log(
`Compiled with ${compiledSources.length} sources:`, `Compiled with ${compiledSources.length} sources:`,
compiledSources.join(", ") compiledSources.join(", "),
); );
const url = args[0] ?? "abode://"; const url = args[0] ?? "abode://";
@@ -31,7 +31,7 @@ for (const source of sources) {
console.log(`- ${source.name}`); console.log(`- ${source.name}`);
console.log( console.log(
" - protocols:", " - protocols:",
source.protocols.map((x) => `'${x}'`).join(" ") source.protocols.map((x) => `'${x}'`).join(" "),
); );
const match = source.checkUrl(url); const match = source.checkUrl(url);
console.log(` - matches url: ${match}`); console.log(` - matches url: ${match}`);
@@ -41,7 +41,7 @@ for (const source of sources) {
console.log( console.log(
` - generates an interface named ${db.name} ${ ` - generates an interface named ${db.name} ${
db.backend ? "with" : "without" db.backend ? "with" : "without"
} backend` } backend`,
); );
await db.close().catch(console.error); await db.close().catch(console.error);
} catch (e) { } catch (e) {
@@ -53,7 +53,7 @@ for (const source of sources) {
console.log( console.log(
` - generates a migrator knowing ${ ` - generates a migrator knowing ${
db.listAvailableMigrations().length db.listAvailableMigrations().length
} migrations` } migrations`,
); );
} catch (e) { } catch (e) {
console.error(e); console.error(e);
+2 -2
View File
@@ -30,7 +30,7 @@ const bgColor = await new Promise<string>((ok, ko) => {
process.stdin.once("data", (chunk) => { process.stdin.once("data", (chunk) => {
const result = chunk.toString("utf8"); const result = chunk.toString("utf8");
const match = result.match( const match = result.match(
/^\u001b]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)$/ /^\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"); if (!match) return ko("Didn't recognize terminal bg color");
const [r, g, b] = match const [r, g, b] = match
@@ -78,7 +78,7 @@ if (import.meta.hot) {
import.meta.hot.accept("../tui/App.js", (mod) => { import.meta.hot.accept("../tui/App.js", (mod) => {
fullscreenApp.instance.rerender( fullscreenApp.instance.rerender(
(mod.app as typeof app)({ db, bgColor, store }) (mod.app as typeof app)({ db, bgColor, store }),
); );
}); });
} }
+3 -3
View File
@@ -39,7 +39,7 @@ export class ApiInterface implements DbInterface {
}: { }: {
headers?: Record<string, string>; headers?: Record<string, string>;
readonly?: boolean; readonly?: boolean;
} = {} } = {},
) { ) {
if (root.endsWith("/")) root = root.slice(0, -1); if (root.endsWith("/")) root = root.slice(0, -1);
this.#root = root; this.#root = root;
@@ -82,7 +82,7 @@ export class ApiInterface implements DbInterface {
params?: Record<string, string>; params?: Record<string, string>;
body?: unknown; body?: unknown;
headers?: Record<string, string>; headers?: Record<string, string>;
} = {} } = {},
): Promise<T> { ): Promise<T> {
const resolvedHeaders = { ...this.#headers, ...headers }; const resolvedHeaders = { ...this.#headers, ...headers };
if (body !== undefined) { if (body !== undefined) {
@@ -247,7 +247,7 @@ export class ApiInterface implements DbInterface {
}); });
} }
async createApikey( async createApikey(
apikey: CreateApikey apikey: CreateApikey,
): Promise<[ClientApikey, `at_${string}`]> { ): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly(); this.#checkReadonly();
const { apikey: key, token } = await this.#call<{ const { apikey: key, token } = await this.#call<{
+1 -1
View File
@@ -3,7 +3,7 @@ import { apiProtocols } from "./url.js";
const getApi = () => const getApi = () =>
import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then( import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then(
(x) => x.default (x) => x.default,
); );
const getApiDynamic: GetDbDynamic = { const getApiDynamic: GetDbDynamic = {
+4 -2
View File
@@ -17,7 +17,9 @@ export function parseApiUrl(url: string) {
urlObj.href = urlObj.href.replace(/^abode\+/, ""); urlObj.href = urlObj.href.replace(/^abode\+/, "");
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0"; const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
const headers = Object.fromEntries( const headers = Object.fromEntries(
[...urlObj.searchParams.entries()].filter(([param]) => param !== "readonly") [...urlObj.searchParams.entries()].filter(
([param]) => param !== "readonly",
),
); );
if (urlObj.username) { if (urlObj.username) {
headers["Authorization"] = headers["Authorization"] =
@@ -26,7 +28,7 @@ export function parseApiUrl(url: string) {
[ [
decodeURIComponent(urlObj.username), decodeURIComponent(urlObj.username),
decodeURIComponent(urlObj.password), decodeURIComponent(urlObj.password),
].join(":") ].join(":"),
); );
urlObj.username = ""; urlObj.username = "";
urlObj.password = ""; urlObj.password = "";
+1 -1
View File
@@ -15,7 +15,7 @@ export async function getDbSources(url: string): Promise<GetDbStatic[]> {
.catch(() => null) .catch(() => null)
.then((dbSource) => { .then((dbSource) => {
if (dbSource) dbSources.push(dbSource); if (dbSource) dbSources.push(dbSource);
}) }),
); );
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import type { GetDbStatic } from "./types/GetDb.js";
let rawGetDbSources: typeof getDbSources | undefined = undefined; let rawGetDbSources: typeof getDbSources | undefined = undefined;
const getGetDbSources = () => const getGetDbSources = () =>
import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then( import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then(
(x) => x.getDbSources (x) => x.getDbSources,
); );
export async function getDbSources(url: string): Promise<GetDbStatic[]> { export async function getDbSources(url: string): Promise<GetDbStatic[]> {
+31 -30
View File
@@ -92,7 +92,7 @@ export class PostgresInterface implements BackendDbInterface {
async getUserByEmail(email: string): Promise<ClientUser> { async getUserByEmail(email: string): Promise<ClientUser> {
const user = await selectClientUser( const user = await selectClientUser(
this.#db, this.#db,
sql`u."email" = ${{ text: email }}` sql`u."email" = ${{ text: email }}`,
); );
if (!user) throw new NotFoundAbodeError(); if (!user) throw new NotFoundAbodeError();
return user; return user;
@@ -111,7 +111,7 @@ export class PostgresInterface implements BackendDbInterface {
SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password" SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password"
FROM "users" FROM "users"
WHERE "email" = ${{ text: email }} WHERE "email" = ${{ text: email }}
` `,
); );
if (!rawUser) throw new NotFoundAbodeError(); if (!rawUser) throw new NotFoundAbodeError();
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError(); if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
@@ -125,7 +125,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "users" DELETE FROM "users"
WHERE "uid" = ${{ uuid: id }} WHERE "uid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -145,10 +145,10 @@ export class PostgresInterface implements BackendDbInterface {
${{ text: user.password }}, ${{ text: user.password }},
${{ jsonb: user.flags }} ${{ jsonb: user.flags }}
) )
` `,
); );
return this.#getUserById(uid, tx); return this.#getUserById(uid, tx);
}) }),
); );
} }
async updateUser(user: UpdateUser): Promise<ClientUser> { async updateUser(user: UpdateUser): Promise<ClientUser> {
@@ -161,7 +161,8 @@ export class PostgresInterface implements BackendDbInterface {
if ("name" in user && user.name !== undefined) if ("name" in user && user.name !== undefined)
updates.push(sql`"name" = ${{ text: user.name }}`); updates.push(sql`"name" = ${{ text: user.name }}`);
if ("password" in user && user.password !== undefined) { if ("password" in user && user.password !== undefined) {
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError(); if (!isValidUserPassword(user.password))
throw new InvalidAbodeError();
if (user.password.startsWith("#")) { if (user.password.startsWith("#")) {
await tx.run(sql` await tx.run(sql`
DELETE FROM "apikeys" DELETE FROM "apikeys"
@@ -185,11 +186,11 @@ export class PostgresInterface implements BackendDbInterface {
"updated_at" = NOW(), "updated_at" = NOW(),
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "uid" = ${{ uuid: user.uid }} WHERE "uid" = ${{ uuid: user.uid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getUserById(user.uid, tx); return this.#getUserById(user.uid, tx);
}) }),
); );
} }
@@ -210,7 +211,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "abodes" DELETE FROM "abodes"
WHERE "aid" = ${{ uuid: id }} WHERE "aid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -228,10 +229,10 @@ export class PostgresInterface implements BackendDbInterface {
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getAbodeById(aid, tx); return this.#getAbodeById(aid, tx);
}) }),
); );
} }
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> { async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
@@ -250,11 +251,11 @@ export class PostgresInterface implements BackendDbInterface {
"updated_by" = ${{ uuid: ctx.uid }}, "updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "aid" = ${{ uuid: abode.aid }} WHERE "aid" = ${{ uuid: abode.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getAbodeById(abode.aid, tx); return this.#getAbodeById(abode.aid, tx);
}) }),
); );
} }
@@ -270,11 +271,11 @@ export class PostgresInterface implements BackendDbInterface {
async #getResidentById( async #getResidentById(
uid: string, uid: string,
aid: string, aid: string,
db: WrappedPgClient db: WrappedPgClient,
): Promise<Resident> { ): Promise<Resident> {
const resident = await selectResident( const resident = await selectResident(
db, db,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}` sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
); );
if (!resident) throw new NotFoundAbodeError(); if (!resident) throw new NotFoundAbodeError();
return resident; return resident;
@@ -288,13 +289,13 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "residents" DELETE FROM "residents"
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }} WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
async createResident( async createResident(
resident: CreateResident, resident: CreateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
return this.#db.rethrow(() => return this.#db.rethrow(() =>
@@ -309,15 +310,15 @@ export class PostgresInterface implements BackendDbInterface {
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getResidentById(resident.uid, resident.aid, tx); return this.#getResidentById(resident.uid, resident.aid, tx);
}) }),
); );
} }
async updateResident( async updateResident(
resident: updateResident, resident: updateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
const updates = calcUpdates({ const updates = calcUpdates({
@@ -336,11 +337,11 @@ export class PostgresInterface implements BackendDbInterface {
WHERE WHERE
"uid" = ${{ uuid: resident.uid }} "uid" = ${{ uuid: resident.uid }}
AND "aid" = ${{ uuid: resident.aid }} AND "aid" = ${{ uuid: resident.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getResidentById(resident.uid, resident.aid, tx); return this.#getResidentById(resident.uid, resident.aid, tx);
}) }),
); );
} }
@@ -350,7 +351,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
JOIN "residents" r ON u."uid" = r."uid" JOIN "residents" r ON u."uid" = r."uid"
WHERE r."aid" = ${{ uuid: id }} WHERE r."aid" = ${{ uuid: id }}
` `,
); );
} }
async listAbodesByUserId(id: string): Promise<Abode[]> { async listAbodesByUserId(id: string): Promise<Abode[]> {
@@ -359,7 +360,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
JOIN "residents" r ON a."aid" = r."aid" JOIN "residents" r ON a."aid" = r."aid"
WHERE r."uid" = ${{ uuid: id }} WHERE r."uid" = ${{ uuid: id }}
` `,
); );
} }
@@ -418,17 +419,17 @@ export class PostgresInterface implements BackendDbInterface {
async #getApikeyByToken( async #getApikeyByToken(
token: `at_${string}`, token: `at_${string}`,
db: WrappedPgClient db: WrappedPgClient,
): Promise<ClientApikey> { ): Promise<ClientApikey> {
const apikey = await selectClientApikey( const apikey = await selectClientApikey(
db, db,
sql`k."token" = ${{ text: token }}` sql`k."token" = ${{ text: token }}`,
); );
if (!apikey) throw new NotFoundAbodeError(); if (!apikey) throw new NotFoundAbodeError();
return apikey; return apikey;
} }
async getUserByApikey( async getUserByApikey(
token: `at_${string}` token: `at_${string}`,
): Promise<[ClientUser, ClientApikey]> { ): Promise<[ClientUser, ClientApikey]> {
const apikey = await this.#getApikeyByToken(token, this.#db); const apikey = await this.#getApikeyByToken(token, this.#db);
if ( if (
@@ -445,13 +446,13 @@ export class PostgresInterface implements BackendDbInterface {
async getApikeyById(kid: string): Promise<ClientApikey> { async getApikeyById(kid: string): Promise<ClientApikey> {
const apikey = await selectClientApikey( const apikey = await selectClientApikey(
this.#db, this.#db,
sql`k."kid" = ${{ uuid: kid }}` sql`k."kid" = ${{ uuid: kid }}`,
); );
if (!apikey) throw new NotFoundAbodeError(); if (!apikey) throw new NotFoundAbodeError();
return apikey; return apikey;
} }
async createApikey( async createApikey(
apikey: CreateApikey apikey: CreateApikey,
): Promise<[ClientApikey, `at_${string}`]> { ): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly(); this.#checkReadonly();
const token = createApikeyToken(); const token = createApikeyToken();
@@ -459,7 +460,7 @@ export class PostgresInterface implements BackendDbInterface {
let expires = apikey.expires_at; let expires = apikey.expires_at;
if (expires === undefined) if (expires === undefined)
expires = new Date( expires = new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
).toISOString(); ).toISOString();
if (expires && new Date(expires).getTime() < Date.now()) if (expires && new Date(expires).getTime() < Date.now())
throw new InvalidAbodeError(); throw new InvalidAbodeError();
+5 -6
View File
@@ -24,7 +24,7 @@ export class PostgresMigrator implements Migrator {
`SELECT EXISTS ( `SELECT EXISTS (
SELECT 1 FROM information_schema.tables SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = '_migrations' WHERE table_schema = 'public' AND table_name = '_migrations'
) AS "exists"` ) AS "exists"`,
); );
if (!existsResult.rows[0]?.exists) return null; if (!existsResult.rows[0]?.exists) return null;
@@ -33,7 +33,7 @@ export class PostgresMigrator implements Migrator {
name: string; name: string;
applied_at: Date; applied_at: Date;
}>( }>(
`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC` `SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`,
); );
return result.rows.map((x) => ({ return result.rows.map((x) => ({
...x, ...x,
@@ -73,17 +73,16 @@ export class PostgresMigrator implements Migrator {
throw new Error(`Applied migration ${id} (${name}) not known`); throw new Error(`Applied migration ${id} (${name}) not known`);
if (migration.name !== name) if (migration.name !== name)
throw new Error( throw new Error(
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})` `Applied migration ${id} (${name}) has a different name from expected (${migration.name})`,
); );
} }
const start = const start = migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
const end = migrations.indexOf(target) + 1; const end = migrations.indexOf(target) + 1;
if (end < start) { if (end < start) {
throw new Error( throw new Error(
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${target.id}` `Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${target.id}`,
); );
} }
+3 -5
View File
@@ -31,9 +31,7 @@ async function rethrow<R>(fn: () => Promise<R>): Promise<R> {
// Rollback on a broken connection can itself throw; the original error is // Rollback on a broken connection can itself throw; the original error is
// the one worth surfacing. // the one worth surfacing.
export async function rollbackQuietly( export async function rollbackQuietly(client: pg.PoolClient): Promise<void> {
client: pg.PoolClient
): Promise<void> {
try { try {
await client.query("ROLLBACK"); await client.query("ROLLBACK");
} catch {} } catch {}
@@ -58,7 +56,7 @@ abstract class WrappedPgBase implements WrappedPgClient {
async all<R>(stmt: SqlCode): Promise<R[]> { async all<R>(stmt: SqlCode): Promise<R[]> {
const result = await this.#queryable.query( const result = await this.#queryable.query(
toPositional(stmt._sql), toPositional(stmt._sql),
stmt._vars stmt._vars,
); );
return result.rows as R[]; return result.rows as R[];
} }
@@ -72,7 +70,7 @@ abstract class WrappedPgBase implements WrappedPgClient {
async run(stmt: SqlCode): Promise<{ changes: number }> { async run(stmt: SqlCode): Promise<{ changes: number }> {
const result = await this.#queryable.query( const result = await this.#queryable.query(
toPositional(stmt._sql), toPositional(stmt._sql),
stmt._vars stmt._vars,
); );
return { changes: result.rowCount ?? 0 }; return { changes: result.rowCount ?? 0 };
} }
+14 -16
View File
@@ -26,20 +26,18 @@ const sqlClientUser = sql`
export async function selectClientUser( export async function selectClientUser(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<ClientUser | null> { ): Promise<ClientUser | null> {
const raw = await db.get<RawClientUser>( const raw = await db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
sql`${sqlClientUser} WHERE ${where}`
);
if (raw) return pgToClientUser(raw); if (raw) return pgToClientUser(raw);
return null; return null;
} }
export async function selectClientUsers( export async function selectClientUsers(
db: WrappedPgClient, db: WrappedPgClient,
rest?: SqlCode rest?: SqlCode,
): Promise<ClientUser[]> { ): Promise<ClientUser[]> {
const rows = await db.all<RawClientUser>( const rows = await db.all<RawClientUser>(
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
); );
return rows.map(pgToClientUser); return rows.map(pgToClientUser);
} }
@@ -59,7 +57,7 @@ const sqlAbode = sql`
export async function selectAbode( export async function selectAbode(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<Abode | null> { ): Promise<Abode | null> {
const raw = await db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`); const raw = await db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`);
if (raw) return pgToAbode(raw); if (raw) return pgToAbode(raw);
@@ -67,10 +65,10 @@ export async function selectAbode(
} }
export async function selectAbodes( export async function selectAbodes(
db: WrappedPgClient, db: WrappedPgClient,
rest?: SqlCode rest?: SqlCode,
): Promise<Abode[]> { ): Promise<Abode[]> {
const rows = await db.all<RawAbode>( const rows = await db.all<RawAbode>(
rest ? sql`${sqlAbode} ${rest}` : sqlAbode rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
); );
return rows.map(pgToAbode); return rows.map(pgToAbode);
} }
@@ -91,7 +89,7 @@ const sqlResident = sql`
export async function selectResident( export async function selectResident(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<Resident | null> { ): Promise<Resident | null> {
const raw = await db.get<RawResident>(sql`${sqlResident} WHERE ${where}`); const raw = await db.get<RawResident>(sql`${sqlResident} WHERE ${where}`);
if (raw) return pgToResident(raw); if (raw) return pgToResident(raw);
@@ -99,10 +97,10 @@ export async function selectResident(
} }
export async function selectResidents( export async function selectResidents(
db: WrappedPgClient, db: WrappedPgClient,
where?: SqlCode where?: SqlCode,
): Promise<Resident[]> { ): Promise<Resident[]> {
const rows = await db.all<RawResident>( const rows = await db.all<RawResident>(
where ? sql`${sqlResident} WHERE ${where}` : sqlResident where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
); );
return rows.map(pgToResident); return rows.map(pgToResident);
} }
@@ -122,20 +120,20 @@ const sqlClientApikey = sql`
export async function selectClientApikey( export async function selectClientApikey(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<ClientApikey | null> { ): Promise<ClientApikey | null> {
const raw = await db.get<RawClientApikey>( const raw = await db.get<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}` sql`${sqlClientApikey} WHERE ${where}`,
); );
if (raw) return pgToClientApikey(raw); if (raw) return pgToClientApikey(raw);
return null; return null;
} }
export async function selectClientApikeys( export async function selectClientApikeys(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<ClientApikey[]> { ): Promise<ClientApikey[]> {
const rows = await db.all<RawClientApikey>( const rows = await db.all<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}` sql`${sqlClientApikey} WHERE ${where}`,
); );
return rows.map(pgToClientApikey); return rows.map(pgToClientApikey);
} }
+1 -1
View File
@@ -65,7 +65,7 @@ export function calcUpdates<T extends object>(updater: {
for (const [prop, update] of Object.entries(updater)) { for (const [prop, update] of Object.entries(updater)) {
if (prop in obj) { if (prop in obj) {
updates.push( updates.push(
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!) (update as (value: unknown) => SqlCode)(obj[prop as keyof T]!),
); );
} }
} }
+4 -1
View File
@@ -9,7 +9,10 @@ export function isPgUrl(url: string): boolean {
} }
} }
export function parsePgUrl(url: string): { connectionString: string; readonly: boolean } { export function parsePgUrl(url: string): {
connectionString: string;
readonly: boolean;
} {
if (!isPgUrl(url)) throw new Error("Not a postgres: URL"); if (!isPgUrl(url)) throw new Error("Not a postgres: URL");
const urlObj = new URL(url); const urlObj = new URL(url);
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0"; const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
+28 -28
View File
@@ -110,7 +110,7 @@ export class SqliteInterface implements BackendDbInterface {
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password" SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
FROM "users" FROM "users"
WHERE "email" = ${{ text: email }} WHERE "email" = ${{ text: email }}
` `,
); );
if (!rawUser) throw new NotFoundAbodeError(); if (!rawUser) throw new NotFoundAbodeError();
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError(); if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
@@ -124,7 +124,7 @@ export class SqliteInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "users" DELETE FROM "users"
WHERE "uid" = ${{ uuid: id }} WHERE "uid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -142,10 +142,10 @@ export class SqliteInterface implements BackendDbInterface {
${{ text: user.email }}, ${{ text: user.email }},
${{ text: user.name }}, ${{ text: user.name }},
${{ text: user.password }},${{ jsonb: user.flags }}) ${{ text: user.password }},${{ jsonb: user.flags }})
` `,
); );
return this.#getUserById(uid); return this.#getUserById(uid);
}) }),
); );
} }
async updateUser(user: UpdateUser): Promise<ClientUser> { async updateUser(user: UpdateUser): Promise<ClientUser> {
@@ -179,11 +179,11 @@ export class SqliteInterface implements BackendDbInterface {
"updated_at" = datetime('now', 'localtime', 'subsec'), "updated_at" = datetime('now', 'localtime', 'subsec'),
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "uid" = ${{ uuid: user.uid }} WHERE "uid" = ${{ uuid: user.uid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getUserById(user.uid); return this.#getUserById(user.uid);
}) }),
); );
} }
@@ -204,7 +204,7 @@ export class SqliteInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "abodes" DELETE FROM "abodes"
WHERE "aid" = ${{ uuid: id }} WHERE "aid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -222,10 +222,10 @@ export class SqliteInterface implements BackendDbInterface {
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getAbodeById(aid); return this.#getAbodeById(aid);
}) }),
); );
} }
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> { async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
@@ -244,11 +244,11 @@ export class SqliteInterface implements BackendDbInterface {
"updated_by" = ${{ uuid: ctx.uid }}, "updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "aid" = ${{ uuid: abode.aid }} WHERE "aid" = ${{ uuid: abode.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getAbodeById(abode.aid); return this.#getAbodeById(abode.aid);
}) }),
); );
} }
@@ -264,7 +264,7 @@ export class SqliteInterface implements BackendDbInterface {
#getResidentById(uid: string, aid: string): Resident { #getResidentById(uid: string, aid: string): Resident {
const resident = selectResident( const resident = selectResident(
this.#db, this.#db,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}` sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
); );
if (!resident) throw new NotFoundAbodeError(); if (!resident) throw new NotFoundAbodeError();
return resident; return resident;
@@ -278,13 +278,13 @@ export class SqliteInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "residents" DELETE FROM "residents"
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }} WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
async createResident( async createResident(
resident: CreateResident, resident: CreateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
return this.#db.rethrow(() => return this.#db.rethrow(() =>
@@ -299,15 +299,15 @@ export class SqliteInterface implements BackendDbInterface {
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getResidentById(resident.uid, resident.aid); return this.#getResidentById(resident.uid, resident.aid);
}) }),
); );
} }
async updateResident( async updateResident(
resident: updateResident, resident: updateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
const updates = calcUpdates({ const updates = calcUpdates({
@@ -326,11 +326,11 @@ export class SqliteInterface implements BackendDbInterface {
WHERE WHERE
"uid" = ${{ uuid: resident.uid }} "uid" = ${{ uuid: resident.uid }}
AND "aid" = ${{ uuid: resident.aid }} AND "aid" = ${{ uuid: resident.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getResidentById(resident.uid, resident.aid); return this.#getResidentById(resident.uid, resident.aid);
}) }),
); );
} }
@@ -340,7 +340,7 @@ export class SqliteInterface implements BackendDbInterface {
sql` sql`
JOIN "residents" r ON u."uid" = r."uid" JOIN "residents" r ON u."uid" = r."uid"
WHERE r."aid" = ${{ uuid: id }} WHERE r."aid" = ${{ uuid: id }}
` `,
); );
} }
async listAbodesByUserId(id: string): Promise<Abode[]> { async listAbodesByUserId(id: string): Promise<Abode[]> {
@@ -349,7 +349,7 @@ export class SqliteInterface implements BackendDbInterface {
sql` sql`
JOIN "residents" r ON a."aid" = r."aid" JOIN "residents" r ON a."aid" = r."aid"
WHERE r."uid" = ${{ uuid: id }} WHERE r."uid" = ${{ uuid: id }}
` `,
); );
} }
@@ -405,13 +405,13 @@ export class SqliteInterface implements BackendDbInterface {
#getApikeyByToken(token: `at_${string}`): ClientApikey { #getApikeyByToken(token: `at_${string}`): ClientApikey {
const apikey = selectClientApikey( const apikey = selectClientApikey(
this.#db, this.#db,
sql`"token" = ${{ text: token }}` sql`"token" = ${{ text: token }}`,
); );
if (!apikey) throw new NotFoundAbodeError(); if (!apikey) throw new NotFoundAbodeError();
return apikey; return apikey;
} }
async getUserByApikey( async getUserByApikey(
token: `at_${string}` token: `at_${string}`,
): Promise<[ClientUser, ClientApikey]> { ): Promise<[ClientUser, ClientApikey]> {
const apikey = this.#getApikeyByToken(token); const apikey = this.#getApikeyByToken(token);
if ( if (
@@ -431,7 +431,7 @@ export class SqliteInterface implements BackendDbInterface {
return apikey; return apikey;
} }
async createApikey( async createApikey(
apikey: CreateApikey apikey: CreateApikey,
): Promise<[ClientApikey, `at_${string}`]> { ): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly(); this.#checkReadonly();
const token = createApikeyToken(); const token = createApikeyToken();
@@ -439,7 +439,7 @@ export class SqliteInterface implements BackendDbInterface {
let expires = apikey.expires_at; let expires = apikey.expires_at;
if (expires === undefined) if (expires === undefined)
expires = new Date( expires = new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
).toISOString(); ).toISOString();
if (expires && new Date(expires).getTime() < Date.now()) if (expires && new Date(expires).getTime() < Date.now())
throw new InvalidAbodeError(); throw new InvalidAbodeError();
@@ -480,7 +480,7 @@ export class SqliteInterface implements BackendDbInterface {
async deleteNoteById(nid: string): Promise<void> { async deleteNoteById(nid: string): Promise<void> {
this.#checkReadonly(); this.#checkReadonly();
const { changes } = this.#db.run( const { changes } = this.#db.run(
sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}` sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}`,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -502,7 +502,7 @@ export class SqliteInterface implements BackendDbInterface {
) )
`); `);
return this.#getNoteById(nid); return this.#getNoteById(nid);
}) }),
); );
} }
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> { async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
@@ -525,7 +525,7 @@ export class SqliteInterface implements BackendDbInterface {
`); `);
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getNoteById(note.nid); return this.#getNoteById(note.nid);
}) }),
); );
} }
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> { async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
+5 -6
View File
@@ -16,12 +16,11 @@ export class SqliteMigrator implements Migrator {
} }
#listAppliedMigrations(): #listAppliedMigrations():
| { id: number; name: string; applied_at: string }[] { id: number; name: string; applied_at: string }[] | null {
| null {
try { try {
return this.#db return this.#db
.all<{ id: number; name: string; applied_at: string }>( .all<{ id: number; name: string; applied_at: string }>(
sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC` sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`,
) )
.map((x) => ({ ...x, applied_at: sqliteToDate(x.applied_at) })); .map((x) => ({ ...x, applied_at: sqliteToDate(x.applied_at) }));
} catch (e) { } catch (e) {
@@ -59,7 +58,7 @@ export class SqliteMigrator implements Migrator {
throw new Error(`Applied migration ${id} (${name}) not known`); throw new Error(`Applied migration ${id} (${name}) not known`);
if (migration.name !== name) if (migration.name !== name)
throw new Error( throw new Error(
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})` `Applied migration ${id} (${name}) has a different name from expected (${migration.name})`,
); );
} }
@@ -70,7 +69,7 @@ export class SqliteMigrator implements Migrator {
throw new Error( throw new Error(
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${ `Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${
target.id target.id
}` }`,
); );
} }
@@ -94,7 +93,7 @@ export class SqliteMigrator implements Migrator {
sql` sql`
INSERT INTO "_migrations"("id", "name") INSERT INTO "_migrations"("id", "name")
VALUES (${{ int: migration.id }}, ${{ text: migration.name }}) VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
` `,
); );
this.#db.run(sql`COMMIT`); this.#db.run(sql`COMMIT`);
} catch (e) { } catch (e) {
+3 -3
View File
@@ -22,7 +22,7 @@ export function uuidToSqlite(uuid: string) {
} }
export function sqliteToUuid(uuid: Buffer | Uint8Array) { export function sqliteToUuid(uuid: Buffer | Uint8Array) {
const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString( const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString(
"hex" "hex",
); );
return [ return [
hex.slice(0, 8), hex.slice(0, 8),
@@ -126,7 +126,7 @@ export function sqliteToResident(resident: {
const defaultApikeyPermissions: ApikeyPermissions = {}; const defaultApikeyPermissions: ApikeyPermissions = {};
export function sqliteToApikeyPermissions( export function sqliteToApikeyPermissions(
permissions: string permissions: string,
): ApikeyPermissions { ): ApikeyPermissions {
const parsed = JSON.parse(permissions); const parsed = JSON.parse(permissions);
const out = { ...defaultApikeyPermissions }; const out = { ...defaultApikeyPermissions };
@@ -184,7 +184,7 @@ export function sqliteToNoteProperties(props: string): NoteProperties {
} }
export function sqliteToPartialNoteProperties( export function sqliteToPartialNoteProperties(
props: string props: string,
): PartialNoteProperties { ): PartialNoteProperties {
const base = sqliteToNoteProperties(props); const base = sqliteToNoteProperties(props);
return { type: base.type ?? "note" }; return { type: base.type ?? "note" };
+1 -1
View File
@@ -3,7 +3,7 @@ import { sqliteProtocols } from "./url.js";
const getSqlite = () => const getSqlite = () =>
import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then( import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then(
(x) => x.default (x) => x.default,
); );
const getSqliteDynamic: GetDbDynamic = { const getSqliteDynamic: GetDbDynamic = {
+1 -1
View File
@@ -6,7 +6,7 @@ import type { WrappedDb, WrappedDbOptions } from "./types.js";
function getDatabase( function getDatabase(
path: string, path: string,
options?: Omit<sqlite.Options, "nativeBinding"> options?: Omit<sqlite.Options, "nativeBinding">,
): sqlite.Database { ): sqlite.Database {
if (!natives.sqlite) throw new Error("No natives found for better-sqlite3"); if (!natives.sqlite) throw new Error("No natives found for better-sqlite3");
options = { ...options }; options = { ...options };
+1 -1
View File
@@ -4,7 +4,7 @@ import { node, bs3 } from "./implementations.js";
export function getWrappedDb( export function getWrappedDb(
kind: "any" | "node" | "bs3", kind: "any" | "node" | "bs3",
path: string, path: string,
options: WrappedDbOptions options: WrappedDbOptions,
): WrappedDb { ): WrappedDb {
if (kind === "node") { if (kind === "node") {
if (!node) throw new Error("Requesting unavailable node backend"); if (!node) throw new Error("Requesting unavailable node backend");
+13 -11
View File
@@ -29,7 +29,7 @@ const sqlClientUser = sql`
export function selectClientUser( export function selectClientUser(
db: WrappedDb, db: WrappedDb,
where: SqlCode where: SqlCode,
): ClientUser | null { ): ClientUser | null {
const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`); const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
if (rawUser) return sqliteToClientUser(rawUser); if (rawUser) return sqliteToClientUser(rawUser);
@@ -37,7 +37,7 @@ export function selectClientUser(
} }
export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] { export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] {
const rawUsers = db.all<RawClientUser>( const rawUsers = db.all<RawClientUser>(
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
); );
return rawUsers.map(sqliteToClientUser); return rawUsers.map(sqliteToClientUser);
} }
@@ -62,7 +62,7 @@ export function selectAbode(db: WrappedDb, where: SqlCode): Abode | null {
} }
export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] { export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] {
const rawAbodes = db.all<RawAbode>( const rawAbodes = db.all<RawAbode>(
rest ? sql`${sqlAbode} ${rest}` : sqlAbode rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
); );
return rawAbodes.map(sqliteToAbode); return rawAbodes.map(sqliteToAbode);
} }
@@ -88,7 +88,7 @@ export function selectResident(db: WrappedDb, where: SqlCode): Resident | null {
} }
export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] { export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] {
const rawResidents = db.all<RawResident>( const rawResidents = db.all<RawResident>(
where ? sql`${sqlResident} WHERE ${where}` : sqlResident where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
); );
return rawResidents.map(sqliteToResident); return rawResidents.map(sqliteToResident);
} }
@@ -108,20 +108,20 @@ const sqlClientApikey = sql`
export function selectClientApikey( export function selectClientApikey(
db: WrappedDb, db: WrappedDb,
where: SqlCode where: SqlCode,
): ClientApikey | null { ): ClientApikey | null {
const rawApikey = db.get<RawClientApikey>( const rawApikey = db.get<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}` sql`${sqlClientApikey} WHERE ${where}`,
); );
if (rawApikey) return sqliteToClientApikey(rawApikey); if (rawApikey) return sqliteToClientApikey(rawApikey);
return null; return null;
} }
export function selectClientApikeys( export function selectClientApikeys(
db: WrappedDb, db: WrappedDb,
where: SqlCode where: SqlCode,
): ClientApikey[] { ): ClientApikey[] {
const rawApikeys = db.all<RawClientApikey>( const rawApikeys = db.all<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}` sql`${sqlClientApikey} WHERE ${where}`,
); );
return rawApikeys.map(sqliteToClientApikey); return rawApikeys.map(sqliteToClientApikey);
} }
@@ -156,15 +156,17 @@ export function selectNote(db: WrappedDb, where: SqlCode): Note | null {
return null; return null;
} }
export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] { export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] {
const raws = db.all<RawNote>(where ? sql`${sqlNote} WHERE ${where}` : sqlNote); const raws = db.all<RawNote>(
where ? sql`${sqlNote} WHERE ${where}` : sqlNote,
);
return raws.map(sqliteToNote); return raws.map(sqliteToNote);
} }
export function selectPartialNotes( export function selectPartialNotes(
db: WrappedDb, db: WrappedDb,
where?: SqlCode where?: SqlCode,
): PartialNote[] { ): PartialNote[] {
const raws = db.all<RawPartialNote>( const raws = db.all<RawPartialNote>(
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote,
); );
return raws.map(sqliteToPartialNote); return raws.map(sqliteToPartialNote);
} }
+1 -1
View File
@@ -65,7 +65,7 @@ export function calcUpdates<T extends object>(updater: {
for (const [prop, update] of Object.entries(updater)) { for (const [prop, update] of Object.entries(updater)) {
if (prop in obj) { if (prop in obj) {
updates.push( updates.push(
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!) (update as (value: unknown) => SqlCode)(obj[prop as keyof T]!),
); );
} }
} }
+3 -3
View File
@@ -27,7 +27,7 @@ export function isSqliteUrl(url: string) {
} }
export function parseSqliteUrl( export function parseSqliteUrl(
url: string url: string,
): ["any" | "node" | "bs3", string, WrappedDbOptions] { ): ["any" | "node" | "bs3", string, WrappedDbOptions] {
if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol"); if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol");
const urlObj = new URL(url); const urlObj = new URL(url);
@@ -39,7 +39,7 @@ export function parseSqliteUrl(
urlObj.protocol === "node+sqlite:" urlObj.protocol === "node+sqlite:"
? "node" ? "node"
: urlObj.protocol === "bs3+sqlite:" : urlObj.protocol === "bs3+sqlite:"
? "bs3" ? "bs3"
: "any"; : "any";
return [kind, urlObj.pathname, options]; return [kind, urlObj.pathname, options];
} }
+3 -3
View File
@@ -40,11 +40,11 @@ export interface DbInterface {
deleteResidentById(uid: string, aid: string): Promise<void>; deleteResidentById(uid: string, aid: string): Promise<void>;
createResident( createResident(
resident: CreateResident, resident: CreateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident>; ): Promise<Resident>;
updateResident( updateResident(
resident: updateResident, resident: updateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident>; ): Promise<Resident>;
// list residents by member // list residents by member
@@ -104,7 +104,7 @@ export function isBackendInterface(db: DbInterface): db is BackendDbInterface {
] as const ] as const
).every( ).every(
(x) => (x) =>
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function" x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function",
) )
); );
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export type LoginUser = {
}; };
export function isValidUserPassword( export function isValidUserPassword(
password: string password: string,
): password is User["password"] { ): password is User["password"] {
if (password.startsWith("#")) { if (password.startsWith("#")) {
return ["unset"].includes(password.slice(1)); return ["unset"].includes(password.slice(1));
+1 -1
View File
@@ -2,7 +2,7 @@ if (import.meta.hot) {
import.meta.hot.on("message", (msg) => { import.meta.hot.on("message", (msg) => {
if ( if (
msg.includes( msg.includes(
"A pending update was not accepted, and reached the root module:" "A pending update was not accepted, and reached the root module:",
) )
) { ) {
throw new Error("[hot] Restarting due to unaccepted pending update"); throw new Error("[hot] Restarting due to unaccepted pending update");
+4 -4
View File
@@ -32,23 +32,23 @@ async function getPackageJsonDir(path: string): Promise<string | null> {
export async function findNative( export async function findNative(
module: string, module: string,
native: string native: string,
): Promise<string> { ): Promise<string> {
const path = await getPackageJsonDir( const path = await getPackageJsonDir(
createRequire(import.meta.url).resolve(module) createRequire(import.meta.url).resolve(module),
); );
if (!path) throw new Error(`Cannot find module directory for ${module}`); if (!path) throw new Error(`Cannot find module directory for ${module}`);
const file = await find(path, native); const file = await find(path, native);
if (!file) if (!file)
throw new Error( throw new Error(
`Cannot find native ${native} of package ${module} in ${path}` `Cannot find native ${native} of package ${module} in ${path}`,
); );
return file; return file;
} }
export async function tryFindNative( export async function tryFindNative(
module: string, module: string,
native: string native: string,
): Promise<string | null> { ): Promise<string | null> {
try { try {
return await findNative(module, native); return await findNative(module, native);
+2 -2
View File
@@ -22,8 +22,8 @@ export async function webpack(): Promise<{ code: string }> {
let code: string = standaloneCode( let code: string = standaloneCode(
validator, validator,
Object.fromEntries( Object.fromEntries(
Object.entries(schemas).map(([id, schema]) => [id, schema.$id]) Object.entries(schemas).map(([id, schema]) => [id, schema.$id]),
) ),
); );
// assign the .schema ourselves to the validation functions // assign the .schema ourselves to the validation functions
+4 -4
View File
@@ -12,7 +12,7 @@ export interface PopupManagerContextData {
openPopup(popup: ComponentType<{ id: string; onClose: () => void }>): string; openPopup(popup: ComponentType<{ id: string; onClose: () => void }>): string;
openPopup<T>( openPopup<T>(
popup: ComponentType<{ id: string; onClose: () => void } & T>, popup: ComponentType<{ id: string; onClose: () => void } & T>,
props: T props: T,
): string; ): string;
closePopup(id: string): void; closePopup(id: string): void;
@@ -34,7 +34,7 @@ export function PopupManager({ children }: { children: ReactNode }) {
const openPopup = useCallback<PopupManagerContextData["openPopup"]>( const openPopup = useCallback<PopupManagerContextData["openPopup"]>(
( (
Component: ComponentType<{ id: string; onClose: () => void }>, Component: ComponentType<{ id: string; onClose: () => void }>,
props = {} props = {},
) => { ) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
Object.assign(props, { Object.assign(props, {
@@ -45,14 +45,14 @@ export function PopupManager({ children }: { children: ReactNode }) {
setPopups((prev) => [...prev, { id, Component, props }]); setPopups((prev) => [...prev, { id, Component, props }]);
return id; return id;
}, },
[] [],
); );
const closePopup = useCallback((id: string) => { const closePopup = useCallback((id: string) => {
setPopups((prev) => prev.filter((x) => x.id !== id)); setPopups((prev) => prev.filter((x) => x.id !== id));
}, []); }, []);
const ctx = useMemo<PopupManagerContextData>( const ctx = useMemo<PopupManagerContextData>(
() => ({ openPopup, closePopup }), () => ({ openPopup, closePopup }),
[] [],
); );
return ( return (
+2 -2
View File
@@ -19,7 +19,7 @@ export function useDataResidentsByAbodeId(aid: string) {
const status = useLoad(loadResidentsByAbodeId, { aid }); const status = useLoad(loadResidentsByAbodeId, { aid });
const residents = useMemo( const residents = useMemo(
() => Object.values(allResidents).filter((x) => x.aid === aid), () => Object.values(allResidents).filter((x) => x.aid === aid),
[allResidents, aid] [allResidents, aid],
); );
return { ...status, residents }; return { ...status, residents };
} }
@@ -29,7 +29,7 @@ export function useDataResidentsByUserId(uid: string) {
const status = useLoad(loadResidentsByUserId, { uid }); const status = useLoad(loadResidentsByUserId, { uid });
const residents = useMemo( const residents = useMemo(
() => Object.values(allResidents).filter((x) => x.uid === uid), () => Object.values(allResidents).filter((x) => x.uid === uid),
[allResidents, uid] [allResidents, uid],
); );
return { ...status, residents }; return { ...status, residents };
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import type { Store } from "../store/store.js";
import { useStore } from "../store/react.js"; import { useStore } from "../store/react.js";
export function useAction<P extends any[], R>( export function useAction<P extends any[], R>(
action: (...params: [...P, { db: DbInterface; store: Store }]) => Promise<R> action: (...params: [...P, { db: DbInterface; store: Store }]) => Promise<R>,
): (...args: P) => Promise<R> { ): (...args: P) => Promise<R> {
const db = use(DbContext); const db = use(DbContext);
const store = useStore(); const store = useStore();
@@ -15,6 +15,6 @@ export function useAction<P extends any[], R>(
if (!db) throw new Error("DB not present"); if (!db) throw new Error("DB not present");
return action(...params, { db, store }); return action(...params, { db, store });
}, },
[action, db] [action, db],
); );
} }
+1 -1
View File
@@ -32,7 +32,7 @@ export function useLoad<P>(loader: Loader<P>, params?: P): UseLoadResult {
const refresh = useCallback( const refresh = useCallback(
() => load({ loader, params: params!, store, db, refresh: true }), () => load({ loader, params: params!, store, db, refresh: true }),
[loader, params, db] [loader, params, db],
); );
return { return {
+3 -3
View File
@@ -8,7 +8,7 @@ import type { Store } from "../store.js";
export async function deleteAbodeById( export async function deleteAbodeById(
aid: string, aid: string,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllAbodes"), waitForLoadIfLoading(store, "loadAllAbodes"),
@@ -22,7 +22,7 @@ export async function deleteAbodeById(
export async function updateAbode( export async function updateAbode(
abode: UpdateAbode, abode: UpdateAbode,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllAbodes"), waitForLoadIfLoading(store, "loadAllAbodes"),
@@ -38,7 +38,7 @@ export async function updateAbode(
export async function createAbode( export async function createAbode(
abode: CreateAbode, abode: CreateAbode,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<string> { ): Promise<string> {
await waitForLoadIfLoading(store, "loadAllAbodes"); await waitForLoadIfLoading(store, "loadAllAbodes");
+3 -3
View File
@@ -7,7 +7,7 @@ import type { Store } from "../store.js";
export async function deleteUserById( export async function deleteUserById(
uid: string, uid: string,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllUsers"), waitForLoadIfLoading(store, "loadAllUsers"),
@@ -24,7 +24,7 @@ export async function deleteUserById(
export async function updateUser( export async function updateUser(
user: UpdateUser, user: UpdateUser,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllUsers"), waitForLoadIfLoading(store, "loadAllUsers"),
@@ -41,7 +41,7 @@ export async function updateUser(
export async function createUser( export async function createUser(
user: CreateUser, user: CreateUser,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<string> { ): Promise<string> {
await waitForLoadIfLoading(store, "loadAllUsers"); await waitForLoadIfLoading(store, "loadAllUsers");
+7 -4
View File
@@ -48,7 +48,7 @@ async function loadImpl<P>({
setLoading([ setLoading([
id, id,
{ status: refresh ? "refreshing" : "loading", type, params }, { status: refresh ? "refreshing" : "loading", type, params },
]) ]),
); );
const controller = new AbortController(); const controller = new AbortController();
try { try {
@@ -69,7 +69,10 @@ async function loadImpl<P>({
store.dispatch(setLoading([id, { status: "loaded", type, params }])); store.dispatch(setLoading([id, { status: "loaded", type, params }]));
} catch (e) { } catch (e) {
store.dispatch( store.dispatch(
setLoading([id, { status: "error", type, params, error: objectError(e) }]) setLoading([
id,
{ status: "error", type, params, error: objectError(e) },
]),
); );
throw e; throw e;
} }
@@ -114,7 +117,7 @@ export function loader<P>(loader: Loader<P>): Loader<P> {
export async function waitForLoadIfLoading( export async function waitForLoadIfLoading(
store: Store, store: Store,
id: string, id: string,
{ signal }: { signal?: AbortSignal } = {} { signal }: { signal?: AbortSignal } = {},
) { ) {
if (!getLoadingStatus(store.getState(), id)) return; if (!getLoadingStatus(store.getState(), id)) return;
return waitFor( return waitFor(
@@ -123,6 +126,6 @@ export async function waitForLoadIfLoading(
const status = getLoadingStatus(state, id); const status = getLoadingStatus(state, id);
return status === "loaded" || status === "error"; return status === "loaded" || status === "error";
}, },
{ signal } { signal },
); );
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export function Provider(
props: Omit<ProviderProps, "context" | "store" | "serverState"> & { props: Omit<ProviderProps, "context" | "store" | "serverState"> & {
store: Store; store: Store;
serverState?: State; serverState?: State;
} },
) { ) {
return <RawProvider context={AbodeStoreContext} {...props} />; return <RawProvider context={AbodeStoreContext} {...props} />;
} }
+1 -1
View File
@@ -40,7 +40,7 @@ const loadingSlice = createSlice({
reducers: { reducers: {
setLoading: ( setLoading: (
state, state,
action: PayloadAction<[id: string, state: LoadingState]> action: PayloadAction<[id: string, state: LoadingState]>,
) => { ) => {
state[action.payload[0]] = action.payload[1]; state[action.payload[0]] = action.payload[1];
}, },
+1 -1
View File
@@ -19,7 +19,7 @@ const usersSlice = createSlice({
getUser: usersSelectors.selectById, getUser: usersSelectors.selectById,
getUserByEmail: (state, email: string) => getUserByEmail: (state, email: string) =>
Object.values(state.entities).find( Object.values(state.entities).find(
(x): x is ClientUser => "email" in x && x.email === email (x): x is ClientUser => "email" in x && x.email === email,
), ),
getUsers: usersSelectors.selectEntities, getUsers: usersSelectors.selectEntities,
getUserIds: usersSelectors.selectIds, getUserIds: usersSelectors.selectIds,
+2 -2
View File
@@ -3,7 +3,7 @@ import type { State, Store } from "./store.js";
export async function waitFor( export async function waitFor(
store: Store, store: Store,
cond: (state: State) => boolean, cond: (state: State) => boolean,
{ signal }: { signal?: AbortSignal } = {} { signal }: { signal?: AbortSignal } = {},
): Promise<void> { ): Promise<void> {
return new Promise<void>((ok, ko) => { return new Promise<void>((ok, ko) => {
signal?.throwIfAborted(); signal?.throwIfAborted();
@@ -28,7 +28,7 @@ export async function waitFor(
() => { () => {
controller.abort(); controller.abort();
}, },
{ signal: controller.signal } { signal: controller.signal },
); );
}); });
} }
+19 -19
View File
@@ -1,23 +1,23 @@
export { default as user } from "./user/user.schema.json" with {type: 'json'}; export { default as user } from "./user/user.schema.json" with { type: "json" };
export { default as createuser } from "./user/createuser.schema.json" with {type: 'json'}; export { default as createuser } from "./user/createuser.schema.json" with { type: "json" };
export { default as updateuser } from "./user/updateuser.schema.json" with {type: 'json'}; export { default as updateuser } from "./user/updateuser.schema.json" with { type: "json" };
export { default as partialuser } from "./user/partialuser.schema.json" with {type: 'json'}; export { default as partialuser } from "./user/partialuser.schema.json" with { type: "json" };
export { default as clientuser } from "./user/clientuser.schema.json" with {type: 'json'}; export { default as clientuser } from "./user/clientuser.schema.json" with { type: "json" };
export { default as userflags } from "./user/userflags.schema.json" with {type: 'json'}; export { default as userflags } from "./user/userflags.schema.json" with { type: "json" };
export { default as loginuser } from "./user/loginuser.schema.json" with {type: 'json'}; export { default as loginuser } from "./user/loginuser.schema.json" with { type: "json" };
export { default as abode } from './abode/abode.schema.json' with {type: 'json'}; export { default as abode } from "./abode/abode.schema.json" with { type: "json" };
export { default as createabode } from './abode/createabode.schema.json' with {type: 'json'}; export { default as createabode } from "./abode/createabode.schema.json" with { type: "json" };
export { default as updateabode } from './abode/updateabode.schema.json' with {type: 'json'}; export { default as updateabode } from "./abode/updateabode.schema.json" with { type: "json" };
export { default as resident } from './resident/resident.schema.json' with {type: 'json'}; export { default as resident } from "./resident/resident.schema.json" with { type: "json" };
export { default as createresident } from './resident/createresident.schema.json' with {type: 'json'}; export { default as createresident } from "./resident/createresident.schema.json" with { type: "json" };
export { default as updateresident } from './resident/updateresident.schema.json' with {type: 'json'}; export { default as updateresident } from "./resident/updateresident.schema.json" with { type: "json" };
export { default as residentflags } from './resident/residentflags.schema.json' with {type: 'json'}; export { default as residentflags } from "./resident/residentflags.schema.json" with { type: "json" };
export { default as createapikey } from './apikey/createapikey.schema.json' with {type: 'json'}; export { default as createapikey } from "./apikey/createapikey.schema.json" with { type: "json" };
export { default as apikeypermissions } from './apikey/apikeypermissions.schema.json' with {type: 'json'}; export { default as apikeypermissions } from "./apikey/apikeypermissions.schema.json" with { type: "json" };
export { default as createnote } from './note/createnote.schema.json' with {type: 'json'}; export { default as createnote } from "./note/createnote.schema.json" with { type: "json" };
export { default as updatenote } from './note/updatenote.schema.json' with {type: 'json'}; export { default as updatenote } from "./note/updatenote.schema.json" with { type: "json" };
export { default as partialnoteproperties } from './note/partialnoteproperties.schema.json' with {type: 'json'}; export { default as partialnoteproperties } from "./note/partialnoteproperties.schema.json" with { type: "json" };
+1 -1
View File
@@ -39,7 +39,7 @@ function checkSchema(name: string, schema: AnySchema) {
if (!schema.$id) throw new Error(`Missing $id for schema ${name}`); if (!schema.$id) throw new Error(`Missing $id for schema ${name}`);
if ( if (
!schema.$id.match( !schema.$id.match(
/^https:\/\/abode\.codi\.moe\/schema\/[a-zA-Z0_9_-]+\.schema\.json$/ /^https:\/\/abode\.codi\.moe\/schema\/[a-zA-Z0_9_-]+\.schema\.json$/,
) )
) )
throw new Error(`Unexpected $id for schema ${name}`); throw new Error(`Unexpected $id for schema ${name}`);
+1 -1
View File
@@ -18,7 +18,7 @@ const validators = Object.fromEntries(
Object.entries(schemas).map(([name, schema]) => [ Object.entries(schemas).map(([name, schema]) => [
name, name,
validator.compile(schema), validator.compile(schema),
]) ]),
) as unknown as { ) as unknown as {
[T in keyof Types]: { [T in keyof Types]: {
(obj: unknown): obj is Types[T]; (obj: unknown): obj is Types[T];
+1 -1
View File
@@ -69,7 +69,7 @@ function App() {
(input, key) => { (input, key) => {
if (input === "q" || key.escape) app.exit(); if (input === "q" || key.escape) app.exit();
}, },
{ isActive } { isActive },
); );
const [activeCollection, setActiveCollection] = const [activeCollection, setActiveCollection] =
+2 -2
View File
@@ -41,11 +41,11 @@ export function AbodesPanel() {
const onSelect = useCallback( const onSelect = useCallback(
(abode: Abode) => openPopup(AbodePopup, { aid: abode.aid }), (abode: Abode) => openPopup(AbodePopup, { aid: abode.aid }),
[openPopup] [openPopup],
); );
const buttons = useMemo<ButtonListItem[]>( const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateAbodePopup) }], () => [{ children: "New", onClick: () => openPopup(CreateAbodePopup) }],
[openPopup] [openPopup],
); );
return ( return (
+2 -2
View File
@@ -59,11 +59,11 @@ export function UsersPanel() {
const onSelect = useCallback( const onSelect = useCallback(
(user: ClientUser | PartialUser) => openPopup(UserPopup, { uid: user.uid }), (user: ClientUser | PartialUser) => openPopup(UserPopup, { uid: user.uid }),
[openPopup] [openPopup],
); );
const buttons = useMemo<ButtonListItem[]>( const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateUserPopup) }], () => [{ children: "New", onClick: () => openPopup(CreateUserPopup) }],
[openPopup] [openPopup],
); );
return ( return (
+2 -2
View File
@@ -18,7 +18,7 @@ export function Button({
(input, key) => { (input, key) => {
if (input === " " || key.return) onClick(); if (input === " " || key.return) onClick();
}, },
{ isActive: isFocused } { isActive: isFocused },
); );
return <Text inverse={isFocused}>[{children}]</Text>; return <Text inverse={isFocused}>[{children}]</Text>;
@@ -66,7 +66,7 @@ export function ButtonList({
setSelected((prev) => (prev - 1 + buttons.length) % buttons.length); setSelected((prev) => (prev - 1 + buttons.length) % buttons.length);
} }
}, },
{ isActive: isFocused || forceFocus || false } { isActive: isFocused || forceFocus || false },
); );
return ( return (
+2 -2
View File
@@ -49,7 +49,7 @@ export function ListBox<T extends string>({
setSelected(items[(items.indexOf(selected) + 1) % items.length]); setSelected(items[(items.indexOf(selected) + 1) % items.length]);
} else if (key.upArrow) { } else if (key.upArrow) {
setSelected( setSelected(
items[(items.indexOf(selected) - 1 + items.length) % items.length] items[(items.indexOf(selected) - 1 + items.length) % items.length],
); );
} else if (key.pageUp) { } else if (key.pageUp) {
setSelected(items[0]); setSelected(items[0]);
@@ -57,7 +57,7 @@ export function ListBox<T extends string>({
setSelected(items[items.length - 1]); setSelected(items[items.length - 1]);
} }
}, },
{ isActive: isFocused } { isActive: isFocused },
); );
return ( return (
+3 -3
View File
@@ -36,14 +36,14 @@ export function ListDisplay<T>({
onSelect?.(items[selected]); onSelect?.(items[selected]);
} }
}, },
{ isActive: isFocused } { isActive: isFocused },
); );
useEffect(() => { useEffect(() => {
if (selected < start) setStart(selected); if (selected < start) setStart(selected);
else if (selected >= slice) else if (selected >= slice)
setStart( setStart(
Math.max(Math.min(selected - slice + start + 1, items.length - 1), 0) Math.max(Math.min(selected - slice + start + 1, items.length - 1), 0),
); );
}, [selected, start, slice, items.length]); }, [selected, start, slice, items.length]);
useEffect(() => { useEffect(() => {
@@ -56,7 +56,7 @@ export function ListDisplay<T>({
const indexed = useMemo( const indexed = useMemo(
() => items.map((item, index) => ({ item, index })), () => items.map((item, index) => ({ item, index })),
[items] [items],
); );
return ( return (
+1 -1
View File
@@ -16,7 +16,7 @@ export function Popup({
(_, key) => { (_, key) => {
if (key.escape) onClose?.(); if (key.escape) onClose?.();
}, },
{ isActive: active && !!onClose } { isActive: active && !!onClose },
); );
return ( return (
+1 -1
View File
@@ -52,7 +52,7 @@ export function SearchPanel<T>({
refresh?.(); refresh?.();
} }
}, },
{ isActive: isFocused && !!refresh } { isActive: isFocused && !!refresh },
); );
const topbar = !!match || !!buttons?.length; const topbar = !!match || !!buttons?.length;
+2 -2
View File
@@ -2,13 +2,13 @@ import { argon2id, argon2Verify } from "hash-wasm";
export async function validatePassword( export async function validatePassword(
password: string, password: string,
hash: string hash: string,
): Promise<boolean> { ): Promise<boolean> {
return await argon2Verify({ password, hash }); return await argon2Verify({ password, hash });
} }
export async function hashPassword( export async function hashPassword(
password: string password: string,
): Promise<`$${string}$${string}`> { ): Promise<`$${string}$${string}`> {
const salt = new Uint8Array(16); const salt = new Uint8Array(16);
crypto.getRandomValues(salt); crypto.getRandomValues(salt);
+12 -14
View File
@@ -7,11 +7,11 @@ const escapes = {
}; };
function parseAdd<T>( function parseAdd<T>(
rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[] rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[],
): [ ): [
props?: Record<string, string>, props?: Record<string, string>,
content?: string, content?: string,
children?: (writer: XmlWriter) => T children?: (writer: XmlWriter) => T,
] { ] {
let props: Record<string, string> | undefined; let props: Record<string, string> | undefined;
let children: ((writer: XmlWriter) => T) | undefined; let children: ((writer: XmlWriter) => T) | undefined;
@@ -55,7 +55,7 @@ export class XmlWriter {
tag: string, tag: string,
props?: Record<string, string>, props?: Record<string, string>,
content?: string, content?: string,
children?: NonNullable<unknown> children?: NonNullable<unknown>,
) { ) {
const top = this.#stack.at(-1); const top = this.#stack.at(-1);
if (top && !top.children) { if (top && !top.children) {
@@ -104,7 +104,7 @@ export class XmlWriter {
add( add(
tag: string, tag: string,
props: Record<string, string>, props: Record<string, string>,
children: (writer: XmlWriter) => void children: (writer: XmlWriter) => void,
): XmlWriter; ): XmlWriter;
add( add(
tag: string, tag: string,
@@ -125,23 +125,21 @@ export class XmlWriter {
addAsync( addAsync(
tag: string, tag: string,
props: Record<string, string>, props: Record<string, string>,
content: string content: string,
): Promise<void>; ): Promise<void>;
addAsync( addAsync(
tag: string, tag: string,
children: (writer: XmlWriter) => Promise<void> children: (writer: XmlWriter) => Promise<void>,
): Promise<void>; ): Promise<void>;
addAsync( addAsync(
tag: string, tag: string,
props: Record<string, string>, props: Record<string, string>,
children: (writer: XmlWriter) => Promise<void> children: (writer: XmlWriter) => Promise<void>,
): Promise<void>; ): Promise<void>;
async addAsync( async addAsync(
tag: string, tag: string,
...rest: ( ...rest: (
| Record<string, string> Record<string, string> | string | ((writer: XmlWriter) => Promise<void>)
| string
| ((writer: XmlWriter) => Promise<void>)
)[] )[]
): Promise<void> { ): Promise<void> {
const [props, content, children] = parseAdd(rest); const [props, content, children] = parseAdd(rest);
@@ -164,7 +162,7 @@ export class XmlWriter {
| Parameters<InstanceType<typeof XmlWriter>["add"]> | Parameters<InstanceType<typeof XmlWriter>["add"]>
| [ | [
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>, NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
...Parameters<InstanceType<typeof XmlWriter>["add"]> ...Parameters<InstanceType<typeof XmlWriter>["add"]>,
] ]
): string { ): string {
let options: ConstructorParameters<typeof XmlWriter>[0]; let options: ConstructorParameters<typeof XmlWriter>[0];
@@ -172,7 +170,7 @@ export class XmlWriter {
options = rest.shift()! as ConstructorParameters<typeof XmlWriter>[0]; options = rest.shift()! as ConstructorParameters<typeof XmlWriter>[0];
} }
return new XmlWriter(options).add( return new XmlWriter(options).add(
...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>) ...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>),
).content; ).content;
} }
@@ -188,7 +186,7 @@ export class XmlWriter {
| Parameters<InstanceType<typeof XmlWriter>["addAsync"]> | Parameters<InstanceType<typeof XmlWriter>["addAsync"]>
| [ | [
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>, NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
...Parameters<InstanceType<typeof XmlWriter>["addAsync"]> ...Parameters<InstanceType<typeof XmlWriter>["addAsync"]>,
] ]
): Promise<string> { ): Promise<string> {
let options: ConstructorParameters<typeof XmlWriter>[0]; let options: ConstructorParameters<typeof XmlWriter>[0];
@@ -197,7 +195,7 @@ export class XmlWriter {
} }
const writer = new XmlWriter(options); const writer = new XmlWriter(options);
await writer.addAsync( await writer.addAsync(
...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>) ...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>),
); );
return writer.content; return writer.content;
} }
+7 -7
View File
@@ -61,7 +61,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: updateuser, includeParams: ["uid"] }), jsonBody({ validate: updateuser, includeParams: ["uid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.updateUser(ctx.request.body); ctx.body = await db.updateUser(ctx.request.body);
} },
); );
router.delete("/users/:uid", async (ctx) => { router.delete("/users/:uid", async (ctx) => {
await db.deleteUserById(ctx.params.uid); await db.deleteUserById(ctx.params.uid);
@@ -82,7 +82,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
async (ctx) => { async (ctx) => {
const [apikey, token] = await db.createApikey(ctx.request.body); const [apikey, token] = await db.createApikey(ctx.request.body);
ctx.body = { apikey, token }; ctx.body = { apikey, token };
} },
); );
router.get("/users/:uid/apikeys/:kid", async (ctx) => { router.get("/users/:uid/apikeys/:kid", async (ctx) => {
const apikey = await db.getApikeyById(ctx.params.kid); const apikey = await db.getApikeyById(ctx.params.kid);
@@ -118,7 +118,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: updateabode, includeParams: ["aid"] }), jsonBody({ validate: updateabode, includeParams: ["aid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.updateAbode(ctx.request.body, { uid: ctx.user!.uid }); ctx.body = await db.updateAbode(ctx.request.body, { uid: ctx.user!.uid });
} },
); );
router.delete("/abodes/:aid", async (ctx) => { router.delete("/abodes/:aid", async (ctx) => {
await db.deleteAbodeById(ctx.params.aid); await db.deleteAbodeById(ctx.params.aid);
@@ -138,7 +138,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: createnote, includeParams: ["aid"] }), jsonBody({ validate: createnote, includeParams: ["aid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.createNote(ctx.request.body, { uid: ctx.user!.uid }); ctx.body = await db.createNote(ctx.request.body, { uid: ctx.user!.uid });
} },
); );
router.use("/residents", authenticate(db)); router.use("/residents", authenticate(db));
@@ -152,7 +152,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.createResident(ctx.request.body, { ctx.body = await db.createResident(ctx.request.body, {
uid: ctx.user!.uid, uid: ctx.user!.uid,
}); });
} },
); );
router.get("/residents/:uid/:aid", async (ctx) => { router.get("/residents/:uid/:aid", async (ctx) => {
ctx.body = await db.getResidentById(ctx.params.uid, ctx.params.aid); ctx.body = await db.getResidentById(ctx.params.uid, ctx.params.aid);
@@ -164,7 +164,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.updateResident(ctx.request.body, { ctx.body = await db.updateResident(ctx.request.body, {
uid: ctx.user!.uid, uid: ctx.user!.uid,
}); });
} },
); );
router.delete("/residents/:uid/:aid", async (ctx) => { router.delete("/residents/:uid/:aid", async (ctx) => {
await db.deleteResidentById(ctx.params.uid, ctx.params.aid); await db.deleteResidentById(ctx.params.uid, ctx.params.aid);
@@ -192,7 +192,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: updatenote, includeParams: ["nid"] }), jsonBody({ validate: updatenote, includeParams: ["nid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid }); ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid });
} },
); );
router.delete("/notes/:nid", async (ctx) => { router.delete("/notes/:nid", async (ctx) => {
await db.deleteNoteById(ctx.params.nid); await db.deleteNoteById(ctx.params.nid);
+1 -1
View File
@@ -16,7 +16,7 @@ export function schemarouter(): KoaRouter {
url: `${ctx.URL}/${name}.schema.json`, url: `${ctx.URL}/${name}.schema.json`,
}, },
]; ];
}) }),
); );
}); });
+19 -11
View File
@@ -3,7 +3,11 @@ import assert from "node:assert/strict";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { ApiInterface } from "../../../src/db/api/ApiInterface.js"; import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
import { apiProtocols, isApiUrl, parseApiUrl } from "../../../src/db/api/url.js"; import {
apiProtocols,
isApiUrl,
parseApiUrl,
} from "../../../src/db/api/url.js";
import { import {
NotFoundAbodeError, NotFoundAbodeError,
NotAuthorizedAbodeError, NotAuthorizedAbodeError,
@@ -86,7 +90,7 @@ describe("parseApiUrl", () => {
it("extra query params become headers", () => { it("extra query params become headers", () => {
const [, { headers }] = parseApiUrl( const [, { headers }] = parseApiUrl(
"http://example.com?X-Custom-Header=value" "http://example.com?X-Custom-Header=value",
); );
assert.equal(headers["X-Custom-Header"], "value"); assert.equal(headers["X-Custom-Header"], "value");
}); });
@@ -94,7 +98,7 @@ describe("parseApiUrl", () => {
it("throws for non-api protocol", () => { it("throws for non-api protocol", () => {
assert.throws( assert.throws(
() => parseApiUrl("sqlite:///db.sqlite"), () => parseApiUrl("sqlite:///db.sqlite"),
/Not an \{abode\+,\}http\{s,\}: protocol/ /Not an \{abode\+,\}http\{s,\}: protocol/,
); );
}); });
}); });
@@ -106,18 +110,22 @@ describe("ApiInterface HTTP error mapping", () => {
before(async () => { before(async () => {
let nextStatus = 500; let nextStatus = 500;
respondWith = (s) => { nextStatus = s; }; respondWith = (s) => {
nextStatus = s;
};
const server = createServer((req, res) => { const server = createServer((req, res) => {
res.writeHead(nextStatus, { "Content-Type": "application/json" }); res.writeHead(nextStatus, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "test" })); res.end(JSON.stringify({ ok: false, error: "test" }));
}); });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const { port } = server.address() as AddressInfo; const { port } = server.address() as AddressInfo;
serverUrl = `http://127.0.0.1:${port}`; serverUrl = `http://127.0.0.1:${port}`;
closeServer = () => closeServer = () =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve())),
); );
}); });
@@ -131,7 +139,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -143,7 +151,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof NotAuthorizedAbodeError); assert.ok(err instanceof NotAuthorizedAbodeError);
return true; return true;
} },
); );
}); });
@@ -155,7 +163,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof ReadonlyAbodeError); assert.ok(err instanceof ReadonlyAbodeError);
return true; return true;
} },
); );
}); });
@@ -167,7 +175,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
@@ -179,7 +187,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof ConflictAbodeError); assert.ok(err instanceof ConflictAbodeError);
return true; return true;
} },
); );
}); });
}); });
+5 -1
View File
@@ -92,7 +92,11 @@ describe("api backend: auth over HTTP", async () => {
const self = await fetch(`${server.url}/auth/self`, { const self = await fetch(`${server.url}/auth/self`, {
headers: { Cookie: `abode_session=${cookie}` }, headers: { Cookie: `abode_session=${cookie}` },
}); });
assert.equal(self.status, 401, "session was invalidated server-side, not just the cookie cleared"); assert.equal(
self.status,
401,
"session was invalidated server-side, not just the cookie cleared",
);
}); });
it("POST /auth/clear-sessions invalidates outstanding session cookies", async () => { it("POST /auth/clear-sessions invalidates outstanding session cookies", async () => {
+2 -2
View File
@@ -10,10 +10,10 @@ import { runAuthTests } from "../../shared/auth.js";
async function createExpiredApikey( async function createExpiredApikey(
db: BackendDbInterface, db: BackendDbInterface,
uid: string uid: string,
): Promise<`at_${string}`> { ): Promise<`at_${string}`> {
const si = db as SqliteInterface; const si = db as SqliteInterface;
const token = (`at_${"e".repeat(32)}`) as `at_${string}`; const token = `at_${"e".repeat(32)}` as `at_${string}`;
const kid = crypto.randomUUID(); const kid = crypto.randomUUID();
const { sql } = si._; const { sql } = si._;
si._.db.run(sql` si._.db.run(sql`
+2 -2
View File
@@ -46,7 +46,7 @@ describe("SqliteMigrator", () => {
assert.equal(applied.length, 3); assert.equal(applied.length, 3);
assert.deepEqual( assert.deepEqual(
applied.map((m) => m.id), applied.map((m) => m.id),
[1, 2, 3] [1, 2, 3],
); );
db.destroy(); db.destroy();
}); });
@@ -66,7 +66,7 @@ describe("SqliteMigrator", () => {
const migrator = new SqliteMigrator(db); const migrator = new SqliteMigrator(db);
await assert.rejects( await assert.rejects(
() => migrator.migrateTo(9999), () => migrator.migrateTo(9999),
/No known migration with id 9999/ /No known migration with id 9999/,
); );
db.destroy(); db.destroy();
}); });
+7 -1
View File
@@ -1,6 +1,12 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { sql, catSql, joinSql, calcUpdates, unsafeSql } from "../../../src/db/sqlite/sql.js"; import {
sql,
catSql,
joinSql,
calcUpdates,
unsafeSql,
} from "../../../src/db/sqlite/sql.js";
describe("sql template tag", () => { describe("sql template tag", () => {
it("produces correct sql and empty vars for plain text", () => { it("produces correct sql and empty vars for plain text", () => {
+22 -10
View File
@@ -10,13 +10,19 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
before(() => { before(() => {
db = makeDb(); db = makeDb();
db.run(unsafeSql("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)")); db.run(
unsafeSql(
"CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)",
),
);
}); });
after(() => db.destroy()); after(() => db.destroy());
it("run INSERT returns changes count", () => { it("run INSERT returns changes count", () => {
const { changes } = db.run(sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`); const { changes } = db.run(
sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`,
);
assert.equal(changes, 1); assert.equal(changes, 1);
}); });
@@ -24,7 +30,9 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
db.run(unsafeSql("DELETE FROM test")); db.run(unsafeSql("DELETE FROM test"));
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "a" }})`); db.run(sql`INSERT INTO test(val) VALUES(${{ text: "a" }})`);
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "b" }})`); db.run(sql`INSERT INTO test(val) VALUES(${{ text: "b" }})`);
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test ORDER BY val")); const rows = db.all<{ val: string }>(
unsafeSql("SELECT val FROM test ORDER BY val"),
);
assert.equal(rows.length, 2); assert.equal(rows.length, 2);
assert.equal(rows[0].val, "a"); assert.equal(rows[0].val, "a");
assert.equal(rows[1].val, "b"); assert.equal(rows[1].val, "b");
@@ -38,7 +46,7 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
assert.equal(row.val, "one"); assert.equal(row.val, "one");
const none = db.get<{ val: string }>( const none = db.get<{ val: string }>(
sql`SELECT val FROM test WHERE val = ${{ text: "none" }}` sql`SELECT val FROM test WHERE val = ${{ text: "none" }}`,
); );
assert.equal(none, null); assert.equal(none, null);
}); });
@@ -49,7 +57,7 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`); db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`);
assert.throws( assert.throws(
() => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")), () => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")),
/Multiple results/ /Multiple results/,
); );
}); });
@@ -69,7 +77,7 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
db.multi(() => { db.multi(() => {
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`); db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`);
throw new Error("abort!"); throw new Error("abort!");
}) }),
); );
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test")); const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
assert.equal(rows.length, 0); assert.equal(rows.length, 0);
@@ -82,7 +90,13 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
it("rethrow propagates non-SQLite errors unchanged", () => { it("rethrow propagates non-SQLite errors unchanged", () => {
const err = new Error("custom error"); const err = new Error("custom error");
assert.throws(() => db.rethrow(() => { throw err; }), (e) => e === err); assert.throws(
() =>
db.rethrow(() => {
throw err;
}),
(e) => e === err,
);
}); });
}); });
} }
@@ -93,9 +107,7 @@ describe("better-sqlite3 WrappedDb", async () => {
let bs3Ctor: (new (path: string) => WrappedDb) | null = null; let bs3Ctor: (new (path: string) => WrappedDb) | null = null;
try { try {
const mod = await import( const mod = await import("../../../src/db/sqlite/impl/better-sqlite3.js");
"../../../src/db/sqlite/impl/better-sqlite3.js"
);
bs3Ctor = mod.WrappedBetterSqlite3Db; bs3Ctor = mod.WrappedBetterSqlite3Db;
} catch { } catch {
// better-sqlite3 not available, skip // better-sqlite3 not available, skip
+2 -2
View File
@@ -10,7 +10,7 @@ export interface TestServer {
} }
export async function createTestServer( export async function createTestServer(
db: BackendDbInterface db: BackendDbInterface,
): Promise<TestServer> { ): Promise<TestServer> {
const app = new Koa(); const app = new Koa();
const router = apirouter(db); const router = apirouter(db);
@@ -23,7 +23,7 @@ export async function createTestServer(
url: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}`,
close: () => close: () =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve())),
), ),
}; };
} }
+27 -12
View File
@@ -1,12 +1,15 @@
import { describe, it, before, after } from "node:test"; import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js"; import type { DbInterface } from "../../src/db/types/DbInterface.js";
import { NotFoundAbodeError, InvalidAbodeError } from "../../src/db/types/errors.js"; import {
NotFoundAbodeError,
InvalidAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js"; import { hashPassword } from "../../src/util/hash.js";
export function runAbodeTests( export function runAbodeTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }> getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: abodes`, async () => { describe(`${name}: abodes`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -28,7 +31,10 @@ export function runAbodeTests(
after(() => close()); after(() => close());
it("createAbode returns an Abode with expected fields", async () => { it("createAbode returns an Abode with expected fields", async () => {
const abode = await db.createAbode({ name: "Test Abode" }, { uid: ctxUid }); const abode = await db.createAbode(
{ name: "Test Abode" },
{ uid: ctxUid },
);
assert.ok(abode.aid, "has aid"); assert.ok(abode.aid, "has aid");
assert.equal(abode.name, "Test Abode"); assert.equal(abode.name, "Test Abode");
assert.ok(abode.created_at); assert.ok(abode.created_at);
@@ -36,7 +42,10 @@ export function runAbodeTests(
}); });
it("getAbodeById returns the created abode", async () => { it("getAbodeById returns the created abode", async () => {
const created = await db.createAbode({ name: "ById Abode" }, { uid: ctxUid }); const created = await db.createAbode(
{ name: "ById Abode" },
{ uid: ctxUid },
);
const found = await db.getAbodeById(created.aid); const found = await db.getAbodeById(created.aid);
assert.equal(found.aid, created.aid); assert.equal(found.aid, created.aid);
assert.equal(found.name, "ById Abode"); assert.equal(found.name, "ById Abode");
@@ -48,14 +57,14 @@ export function runAbodeTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
it("listAbodes includes the created abode", async () => { it("listAbodes includes the created abode", async () => {
const created = await db.createAbode( const created = await db.createAbode(
{ name: `Listed Abode ${Date.now()}` }, { name: `Listed Abode ${Date.now()}` },
{ uid: ctxUid } { uid: ctxUid },
); );
const abodes = await db.listAbodes(); const abodes = await db.listAbodes();
assert.ok(Array.isArray(abodes)); assert.ok(Array.isArray(abodes));
@@ -67,32 +76,38 @@ export function runAbodeTests(
const created = await db.createAbode({ name: "Before" }, { uid: ctxUid }); const created = await db.createAbode({ name: "Before" }, { uid: ctxUid });
const updated = await db.updateAbode( const updated = await db.updateAbode(
{ aid: created.aid, name: "After" }, { aid: created.aid, name: "After" },
{ uid: ctxUid } { uid: ctxUid },
); );
assert.equal(updated.aid, created.aid); assert.equal(updated.aid, created.aid);
assert.equal(updated.name, "After"); assert.equal(updated.name, "After");
}); });
it("updateAbode with no fields throws InvalidAbodeError", async () => { it("updateAbode with no fields throws InvalidAbodeError", async () => {
const created = await db.createAbode({ name: "No Update" }, { uid: ctxUid }); const created = await db.createAbode(
{ name: "No Update" },
{ uid: ctxUid },
);
await assert.rejects( await assert.rejects(
() => db.updateAbode({ aid: created.aid }, { uid: ctxUid }), () => db.updateAbode({ aid: created.aid }, { uid: ctxUid }),
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
it("deleteAbodeById removes the abode", async () => { it("deleteAbodeById removes the abode", async () => {
const created = await db.createAbode({ name: "To Delete" }, { uid: ctxUid }); const created = await db.createAbode(
{ name: "To Delete" },
{ uid: ctxUid },
);
await db.deleteAbodeById(created.aid); await db.deleteAbodeById(created.aid);
await assert.rejects( await assert.rejects(
() => db.getAbodeById(created.aid), () => db.getAbodeById(created.aid),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -102,7 +117,7 @@ export function runAbodeTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+4 -4
View File
@@ -6,7 +6,7 @@ import { hashPassword } from "../../src/util/hash.js";
export function runApikeyTests( export function runApikeyTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }> getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: apikeys`, async () => { describe(`${name}: apikeys`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -69,7 +69,7 @@ export function runApikeyTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -85,7 +85,7 @@ export function runApikeyTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -95,7 +95,7 @@ export function runApikeyTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+15 -7
View File
@@ -11,7 +11,10 @@ import { hashPassword } from "../../src/util/hash.js";
export function runAuthTests( export function runAuthTests(
name: string, name: string,
getDb: () => Promise<{ db: BackendDbInterface; close(): void }>, getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
createExpiredApikey?: (db: BackendDbInterface, uid: string) => Promise<`at_${string}`> createExpiredApikey?: (
db: BackendDbInterface,
uid: string,
) => Promise<`at_${string}`>,
): void { ): void {
describe(`${name}: auth`, async () => { describe(`${name}: auth`, async () => {
let db: BackendDbInterface; let db: BackendDbInterface;
@@ -23,7 +26,12 @@ export function runAuthTests(
({ db, close } = await getDb()); ({ db, close } = await getDb());
email = `auth-user-${Date.now()}@test.example`; email = `auth-user-${Date.now()}@test.example`;
const pw = await hashPassword(password); const pw = await hashPassword(password);
await db.createUser({ email, name: "Auth User", password: pw, flags: {} }); await db.createUser({
email,
name: "Auth User",
password: pw,
flags: {},
});
}); });
after(() => close()); after(() => close());
@@ -40,7 +48,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotAuthorizedAbodeError); assert.ok(err instanceof NotAuthorizedAbodeError);
return true; return true;
} },
); );
}); });
@@ -54,7 +62,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -71,7 +79,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof ConflictAbodeError); assert.ok(err instanceof ConflictAbodeError);
return true; return true;
} },
); );
}); });
}); });
@@ -118,7 +126,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotAuthorizedAbodeError); assert.ok(err instanceof NotAuthorizedAbodeError);
return true; return true;
} },
); );
}); });
@@ -129,7 +137,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+21 -12
View File
@@ -1,12 +1,15 @@
import { describe, it, before, after } from "node:test"; import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js"; import type { DbInterface } from "../../src/db/types/DbInterface.js";
import { NotFoundAbodeError, InvalidAbodeError } from "../../src/db/types/errors.js"; import {
NotFoundAbodeError,
InvalidAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js"; import { hashPassword } from "../../src/util/hash.js";
export function runResidentTests( export function runResidentTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }> getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: residents`, async () => { describe(`${name}: residents`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -36,7 +39,7 @@ export function runResidentTests(
uid = resUser.uid; uid = resUser.uid;
const abode = await db.createAbode( const abode = await db.createAbode(
{ name: `Resident Abode ${Date.now()}` }, { name: `Resident Abode ${Date.now()}` },
{ uid: ctxUid } { uid: ctxUid },
); );
aid = abode.aid; aid = abode.aid;
await db.createResident({ uid, aid, flags: {} }, { uid: ctxUid }); await db.createResident({ uid, aid, flags: {} }, { uid: ctxUid });
@@ -56,12 +59,12 @@ export function runResidentTests(
() => () =>
db.getResidentById( db.getResidentById(
"00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000",
"00000000-0000-0000-0000-000000000001" "00000000-0000-0000-0000-000000000001",
), ),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -101,7 +104,7 @@ export function runResidentTests(
it("updateResident updates flags", async () => { it("updateResident updates flags", async () => {
const updated = await db.updateResident( const updated = await db.updateResident(
{ uid, aid, flags: { admin: true } }, { uid, aid, flags: { admin: true } },
{ uid: ctxUid } { uid: ctxUid },
); );
assert.equal(updated.uid, uid); assert.equal(updated.uid, uid);
assert.deepEqual(updated.flags, { admin: true }); assert.deepEqual(updated.flags, { admin: true });
@@ -113,7 +116,7 @@ export function runResidentTests(
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
@@ -125,15 +128,21 @@ export function runResidentTests(
password: pw, password: pw,
flags: {}, flags: {},
}); });
const abode2 = await db.createAbode({ name: "Del Abode" }, { uid: ctxUid }); const abode2 = await db.createAbode(
await db.createResident({ uid: user2.uid, aid: abode2.aid, flags: {} }, { uid: ctxUid }); { name: "Del Abode" },
{ uid: ctxUid },
);
await db.createResident(
{ uid: user2.uid, aid: abode2.aid, flags: {} },
{ uid: ctxUid },
);
await db.deleteResidentById(user2.uid, abode2.aid); await db.deleteResidentById(user2.uid, abode2.aid);
await assert.rejects( await assert.rejects(
() => db.getResidentById(user2.uid, abode2.aid), () => db.getResidentById(user2.uid, abode2.aid),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -142,12 +151,12 @@ export function runResidentTests(
() => () =>
db.deleteResidentById( db.deleteResidentById(
"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003" "00000000-0000-0000-0000-000000000003",
), ),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+3 -3
View File
@@ -6,7 +6,7 @@ import { hashPassword } from "../../src/util/hash.js";
export function runSessionTests( export function runSessionTests(
name: string, name: string,
getDb: () => Promise<{ db: BackendDbInterface; close(): void }> getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
): void { ): void {
describe(`${name}: sessions`, async () => { describe(`${name}: sessions`, async () => {
let db: BackendDbInterface; let db: BackendDbInterface;
@@ -46,7 +46,7 @@ export function runSessionTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -58,7 +58,7 @@ export function runSessionTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+17 -9
View File
@@ -11,7 +11,7 @@ import { hashPassword } from "../../src/util/hash.js";
export function runUserTests( export function runUserTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }>, getDb: () => Promise<{ db: DbInterface; close(): void }>,
getReadonlyDb?: () => Promise<{ db: DbInterface; close(): void }> getReadonlyDb?: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: users`, async () => { describe(`${name}: users`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -57,13 +57,18 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
it("getUserByEmail returns the created user", async () => { it("getUserByEmail returns the created user", async () => {
const email = `user-byemail-${Date.now()}@test.example`; const email = `user-byemail-${Date.now()}@test.example`;
await db.createUser({ email, name: "ByEmail User", password: hashedPw, flags: {} }); await db.createUser({
email,
name: "ByEmail User",
password: hashedPw,
flags: {},
});
const found = await db.getUserByEmail(email); const found = await db.getUserByEmail(email);
assert.ok("email" in found, "result includes email"); assert.ok("email" in found, "result includes email");
assert.equal(found.email, email); assert.equal(found.email, email);
@@ -75,7 +80,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -100,7 +105,10 @@ export function runUserTests(
password: hashedPw, password: hashedPw,
flags: {}, flags: {},
}); });
const updated = await db.updateUser({ uid: created.uid, name: "After Update" }); const updated = await db.updateUser({
uid: created.uid,
name: "After Update",
});
assert.equal(updated.uid, created.uid); assert.equal(updated.uid, created.uid);
assert.equal(updated.name, "After Update"); assert.equal(updated.name, "After Update");
}); });
@@ -117,7 +125,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
@@ -134,7 +142,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -144,7 +152,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
@@ -178,7 +186,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof ReadonlyAbodeError); assert.ok(err instanceof ReadonlyAbodeError);
return true; return true;
} },
); );
}); });
}); });
+110 -29
View File
@@ -29,7 +29,7 @@ const MOCK_APIKEY: ClientApikey = {
}; };
function makeMockDb( function makeMockDb(
overrides: Partial<BackendDbInterface> = {} overrides: Partial<BackendDbInterface> = {},
): BackendDbInterface { ): BackendDbInterface {
return { return {
readonly: false, readonly: false,
@@ -37,42 +37,97 @@ function makeMockDb(
name: "mock", name: "mock",
close: async () => {}, close: async () => {},
listUsers: async () => [], listUsers: async () => [],
getUserById: async () => { throw new NotFoundAbodeError(); }, getUserById: async () => {
throw new NotFoundAbodeError();
},
deleteUserById: async () => {}, deleteUserById: async () => {},
createUser: async () => MOCK_USER, createUser: async () => MOCK_USER,
updateUser: async () => MOCK_USER, updateUser: async () => MOCK_USER,
getUserByEmail: async () => { throw new NotFoundAbodeError(); }, getUserByEmail: async () => {
throw new NotFoundAbodeError();
},
listAbodes: async () => [], listAbodes: async () => [],
getAbodeById: async () => { throw new NotFoundAbodeError(); }, getAbodeById: async () => {
throw new NotFoundAbodeError();
},
deleteAbodeById: async () => {}, deleteAbodeById: async () => {},
createAbode: async () => ({ aid: "a", name: "A", created_at: "", created_by: null, updated_at: "", updated_by: null }), createAbode: async () => ({
updateAbode: async () => ({ aid: "a", name: "A", created_at: "", created_by: null, updated_at: "", updated_by: null }), aid: "a",
name: "A",
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
updateAbode: async () => ({
aid: "a",
name: "A",
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
listResidents: async () => [], listResidents: async () => [],
getResidentById: async () => { throw new NotFoundAbodeError(); }, getResidentById: async () => {
throw new NotFoundAbodeError();
},
deleteResidentById: async () => {}, deleteResidentById: async () => {},
createResident: async () => ({ uid: "", aid: "", flags: {}, created_at: "", created_by: null, updated_at: "", updated_by: null }), createResident: async () => ({
updateResident: async () => ({ uid: "", aid: "", flags: {}, created_at: "", created_by: null, updated_at: "", updated_by: null }), uid: "",
aid: "",
flags: {},
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
updateResident: async () => ({
uid: "",
aid: "",
flags: {},
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
listResidentsByUserId: async () => [], listResidentsByUserId: async () => [],
listResidentsByAbodeId: async () => [], listResidentsByAbodeId: async () => [],
listUsersByAbodeId: async () => [], listUsersByAbodeId: async () => [],
listAbodesByUserId: async () => [], listAbodesByUserId: async () => [],
listNotes: async () => [], listNotes: async () => [],
getNoteById: async () => { throw new NotFoundAbodeError(); }, getNoteById: async () => {
throw new NotFoundAbodeError();
},
deleteNoteById: async () => {}, deleteNoteById: async () => {},
createNote: async () => { throw new Error("unimplemented"); }, createNote: async () => {
updateNote: async () => { throw new Error("unimplemented"); }, throw new Error("unimplemented");
},
updateNote: async () => {
throw new Error("unimplemented");
},
listNotesByAbodeId: async () => [], listNotesByAbodeId: async () => [],
listNotesByUserId: async () => [], listNotesByUserId: async () => [],
deleteSessionsByUser: async () => {}, deleteSessionsByUser: async () => {},
listApikeysByUser: async () => [], listApikeysByUser: async () => [],
getApikeyById: async () => { throw new NotFoundAbodeError(); }, getApikeyById: async () => {
createApikey: async () => [MOCK_APIKEY, "at_" + "0".repeat(32) as `at_${string}`], throw new NotFoundAbodeError();
},
createApikey: async () => [
MOCK_APIKEY,
("at_" + "0".repeat(32)) as `at_${string}`,
],
deleteApikeyById: async () => {}, deleteApikeyById: async () => {},
getUserByLogin: async () => { throw new NotFoundAbodeError(); }, getUserByLogin: async () => {
getUserBySession: async () => { throw new NotFoundAbodeError(); }, throw new NotFoundAbodeError();
},
getUserBySession: async () => {
throw new NotFoundAbodeError();
},
createSession: async () => `as_${"0".repeat(32)}`, createSession: async () => `as_${"0".repeat(32)}`,
deleteSession: async () => {}, deleteSession: async () => {},
getUserByApikey: async () => { throw new NotFoundAbodeError(); }, getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
...overrides, ...overrides,
}; };
} }
@@ -92,7 +147,10 @@ type MockCtx = {
}; };
}; };
function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrides: Record<string, string> = {}): MockCtx { function makeMockCtx(
headerOverrides: Record<string, string> = {},
cookieOverrides: Record<string, string> = {},
): MockCtx {
const clearedCookies = new Set<string>(); const clearedCookies = new Set<string>();
const ctx: MockCtx = { const ctx: MockCtx = {
headers: headerOverrides, headers: headerOverrides,
@@ -110,7 +168,10 @@ function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrid
return cookieOverrides[name]; return cookieOverrides[name];
}, },
set(name: string, value: string, opts?: unknown) { set(name: string, value: string, opts?: unknown) {
if (value === "" || (opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000)) { if (
value === "" ||
(opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000)
) {
clearedCookies.add(name); clearedCookies.add(name);
} }
}, },
@@ -121,11 +182,13 @@ function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrid
async function runMiddleware( async function runMiddleware(
db: BackendDbInterface, db: BackendDbInterface,
ctx: MockCtx ctx: MockCtx,
): Promise<boolean> { ): Promise<boolean> {
let nextCalled = false; let nextCalled = false;
const mw = authenticate(db); const mw = authenticate(db);
await mw(ctx as any, async () => { nextCalled = true; }); await mw(ctx as any, async () => {
nextCalled = true;
});
return nextCalled; return nextCalled;
} }
@@ -157,7 +220,9 @@ describe("authenticate middleware", () => {
it("wrong password (NotAuthorizedAbodeError) → 401 invalid_password", async () => { it("wrong password (NotAuthorizedAbodeError) → 401 invalid_password", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByLogin: async () => { throw new NotAuthorizedAbodeError(); }, getUserByLogin: async () => {
throw new NotAuthorizedAbodeError();
},
}); });
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:wrong") }); const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:wrong") });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
@@ -167,9 +232,13 @@ describe("authenticate middleware", () => {
it("unknown user (NotFoundAbodeError) → 401 unknown_user", async () => { it("unknown user (NotFoundAbodeError) → 401 unknown_user", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByLogin: async () => { throw new NotFoundAbodeError(); }, getUserByLogin: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({
Authorization: "Basic " + btoa("nobody:pass"),
}); });
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("nobody:pass") });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
assert.equal(ctx.status, 401); assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "unknown_user"); assert.deepEqual((ctx.body as any)?.error, "unknown_user");
@@ -177,7 +246,9 @@ describe("authenticate middleware", () => {
it("ConflictAbodeError (#unset password) → 401 user_not_loggable", async () => { it("ConflictAbodeError (#unset password) → 401 user_not_loggable", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByLogin: async () => { throw new ConflictAbodeError(); }, getUserByLogin: async () => {
throw new ConflictAbodeError();
},
}); });
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:pass") }); const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:pass") });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
@@ -211,7 +282,9 @@ describe("authenticate middleware", () => {
it("invalid/expired at_ token → 401 invalid_apikey", async () => { it("invalid/expired at_ token → 401 invalid_apikey", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByApikey: async () => { throw new NotFoundAbodeError(); }, getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
}); });
const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` }); const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
@@ -246,17 +319,25 @@ describe("authenticate middleware", () => {
const db = makeMockDb(); const db = makeMockDb();
const ctx = makeMockCtx({}, { abode_session: "not-a-session-token" }); const ctx = makeMockCtx({}, { abode_session: "not-a-session-token" });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
assert.ok(ctx.clearedCookies.has("abode_session"), "cookie should be cleared"); assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401); assert.equal(ctx.status, 401);
}); });
it("expired/unknown session token clears cookie and returns 401", async () => { it("expired/unknown session token clears cookie and returns 401", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserBySession: async () => { throw new NotFoundAbodeError(); }, getUserBySession: async () => {
throw new NotFoundAbodeError();
},
}); });
const ctx = makeMockCtx({}, { abode_session: validToken }); const ctx = makeMockCtx({}, { abode_session: validToken });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
assert.ok(ctx.clearedCookies.has("abode_session"), "cookie should be cleared"); assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401); assert.equal(ctx.status, 401);
}); });
}); });
+3 -1
View File
@@ -25,7 +25,9 @@ describe("convertError middleware", () => {
it("does not interfere when next succeeds", async () => { it("does not interfere when next succeeds", async () => {
const ctx = makeCtx(); const ctx = makeCtx();
let nextCalled = false; let nextCalled = false;
await convertError(ctx as any, async () => { nextCalled = true; }); await convertError(ctx as any, async () => {
nextCalled = true;
});
assert.equal(nextCalled, true); assert.equal(nextCalled, true);
assert.equal(ctx.status, 200); assert.equal(ctx.status, 200);
}); });
+20 -14
View File
@@ -8,7 +8,7 @@ import { jsonBody } from "../../src/webapi/middleware/jsonBody.js";
async function request( async function request(
url: string, url: string,
opts: { method?: string; body?: unknown; contentType?: string } = {} opts: { method?: string; body?: unknown; contentType?: string } = {},
): Promise<{ status: number; body: unknown }> { ): Promise<{ status: number; body: unknown }> {
const method = opts.method ?? "POST"; const method = opts.method ?? "POST";
const bodyStr = const bodyStr =
@@ -21,18 +21,19 @@ async function request(
const res = await fetch(url, { method, headers, body: bodyStr }); const res = await fetch(url, { method, headers, body: bodyStr });
const text = await res.text(); const text = await res.text();
let parsed: unknown; let parsed: unknown;
try { parsed = JSON.parse(text); } catch { parsed = text; } try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
return { status: res.status, body: parsed }; return { status: res.status, body: parsed };
} }
async function makeTestServer() { async function makeTestServer() {
const failValidator = Object.assign( const failValidator = Object.assign((_obj: unknown): _obj is never => false, {
(_obj: unknown): _obj is never => false, errors: [{ message: "required" }] as unknown[],
{ schema: { $id: "test-schema", title: "Test", description: "" },
errors: [{ message: "required" }] as unknown[], });
schema: { $id: "test-schema", title: "Test", description: "" },
}
);
const app = new Koa(); const app = new Koa();
const router = new KoaRouter(); const router = new KoaRouter();
@@ -48,7 +49,7 @@ async function makeTestServer() {
async (ctx) => { async (ctx) => {
ctx.status = 200; ctx.status = 200;
ctx.body = { ok: true }; ctx.body = { ok: true };
} },
); );
router.post( router.post(
@@ -57,7 +58,7 @@ async function makeTestServer() {
async (ctx) => { async (ctx) => {
ctx.status = 200; ctx.status = 200;
ctx.body = { ok: true, body: ctx.request.body }; ctx.body = { ok: true, body: ctx.request.body };
} },
); );
app.use(router.routes()); app.use(router.routes());
@@ -69,7 +70,7 @@ async function makeTestServer() {
const url = `http://127.0.0.1:${port}`; const url = `http://127.0.0.1:${port}`;
const close = () => const close = () =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve())),
); );
return { url, close }; return { url, close };
} }
@@ -107,11 +108,16 @@ describe("jsonBody middleware", () => {
}); });
it("failing validator → 400 jsonchema_validation_failed with schema and errors", async () => { it("failing validator → 400 jsonchema_validation_failed with schema and errors", async () => {
const res = await request(url + "/fail-validate", { body: { any: "thing" } }); const res = await request(url + "/fail-validate", {
body: { any: "thing" },
});
assert.equal(res.status, 400); assert.equal(res.status, 400);
assert.equal((res.body as any).error, "jsonchema_validation_failed"); assert.equal((res.body as any).error, "jsonchema_validation_failed");
assert.ok((res.body as any).schema, "response includes schema"); assert.ok((res.body as any).schema, "response includes schema");
assert.ok(Array.isArray((res.body as any).errors), "response includes errors"); assert.ok(
Array.isArray((res.body as any).errors),
"response includes errors",
);
}); });
it("includeParams: param absent from body → merged in", async () => { it("includeParams: param absent from body → merged in", async () => {
+14 -14
View File
@@ -1,15 +1,15 @@
{ {
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"strict": true, "strict": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"module": "nodenext", "module": "nodenext",
"target": "esnext", "target": "esnext",
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"noEmit": true, "noEmit": true,
"sourceMap": true, "sourceMap": true,
"jsx": "react-jsxdev" "jsx": "react-jsxdev"
}, },
"include": ["src/**/*.ts","src/**/*.tsx"] "include": ["src/**/*.ts", "src/**/*.tsx"]
} }
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "." "rootDir": "."
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"] "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
} }
+12 -12
View File
@@ -25,8 +25,8 @@ export default async (): Promise<Configuration[]> => {
process.env.DB_SOURCES === "dynamic" process.env.DB_SOURCES === "dynamic"
? "dynamic" ? "dynamic"
: process.env.DB_SOURCES === "static" : process.env.DB_SOURCES === "static"
? "static" ? "static"
: "shared"; : "shared";
let disableDbSqlite = process.env.DISABLE_DB_SQLITE === "1"; let disableDbSqlite = process.env.DISABLE_DB_SQLITE === "1";
let disableBs3 = disableDbSqlite || process.env.DISABLE_BS3 === "1"; let disableBs3 = disableDbSqlite || process.env.DISABLE_BS3 === "1";
@@ -56,7 +56,7 @@ export default async (): Promise<Configuration[]> => {
(await readdir(file("./src/bin"))).map((bin) => [ (await readdir(file("./src/bin"))).map((bin) => [
bin.replace(/\..+$/, ""), bin.replace(/\..+$/, ""),
file(`./src/bin/${bin}`), file(`./src/bin/${bin}`),
]) ]),
); );
for (const bin of Object.keys(binaries)) { for (const bin of Object.keys(binaries)) {
copies.push({ copies.push({
@@ -93,12 +93,12 @@ export default async (): Promise<Configuration[]> => {
} else if (dbSources === "static") { } else if (dbSources === "static") {
console.log("Resolving db interfaces statically"); console.log("Resolving db interfaces statically");
aliases[file("./src/db/dbSources.ts")] = file( aliases[file("./src/db/dbSources.ts")] = file(
"./src/db/dbSources.static.ts" "./src/db/dbSources.static.ts",
); );
} else { } else {
console.log("Resolving db interfaces shared"); console.log("Resolving db interfaces shared");
aliases[file("./src/db/dbSources.ts")] = file( aliases[file("./src/db/dbSources.ts")] = file(
"./src/db/dbSources.shared.ts" "./src/db/dbSources.shared.ts",
); );
} }
@@ -111,21 +111,21 @@ export default async (): Promise<Configuration[]> => {
} else if (disableBs3) { } else if (disableBs3) {
console.log("Disabling better-sqlite3 sqlite db backend"); console.log("Disabling better-sqlite3 sqlite db backend");
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file( aliases[file("./src/db/sqlite/impl/implementations.ts")] = file(
"./src/db/sqlite/impl/implementations.node.ts" "./src/db/sqlite/impl/implementations.node.ts",
); );
compiledSources.push("sqlite"); compiledSources.push("sqlite");
} else if (disableNodeSqlite) { } else if (disableNodeSqlite) {
console.log("Disabling node:sqlite sqlite db backend"); console.log("Disabling node:sqlite sqlite db backend");
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file( aliases[file("./src/db/sqlite/impl/implementations.ts")] = file(
"./src/db/sqlite/impl/implementations.bs3.ts" "./src/db/sqlite/impl/implementations.bs3.ts",
); );
compiledSources.push("sqlite"); compiledSources.push("sqlite");
} else { } else {
console.log( console.log(
"Enabling sqlite db interface with better-sqlite3 and node:sqlite backends" "Enabling sqlite db interface with better-sqlite3 and node:sqlite backends",
); );
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file( aliases[file("./src/db/sqlite/impl/implementations.ts")] = file(
"./src/db/sqlite/impl/implementations.all.ts" "./src/db/sqlite/impl/implementations.all.ts",
); );
compiledSources.push("sqlite"); compiledSources.push("sqlite");
} }
@@ -145,18 +145,18 @@ export default async (): Promise<Configuration[]> => {
defines.compiledSources = JSON.stringify(compiledSources); defines.compiledSources = JSON.stringify(compiledSources);
for (const source of existingSources) for (const source of existingSources)
defines[`compiledSources.${source}`] = JSON.stringify( defines[`compiledSources.${source}`] = JSON.stringify(
compiledSources.includes(source) compiledSources.includes(source),
); );
if (!compiledSources.length) { if (!compiledSources.length) {
console.warn( console.warn(
"No db interface enabled, the builds will be completely useless" "No db interface enabled, the builds will be completely useless",
); );
process.exit(1); process.exit(1);
} }
// log about the natives we have // log about the natives we have
console.log( console.log(
`Using ${Object.values(natives).filter(Boolean).length} natives:` `Using ${Object.values(natives).filter(Boolean).length} natives:`,
); );
for (const [key, path] of Object.entries(natives)) { for (const [key, path] of Object.entries(natives)) {
if (path) console.log(`- ${key}: ${path}`); if (path) console.log(`- ${key}: ${path}`);