Compare commits
14
Commits
bc787a208a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0355bd0b2e | ||
|
|
1e10391e20 | ||
|
|
a0a436c4b8 | ||
|
|
d7e31dfce9 | ||
|
|
3a56dcd9e5 | ||
|
|
aadc950e24 | ||
|
|
ccb970f200 | ||
|
|
31d4636dde | ||
|
|
b9fa79ff1f | ||
|
|
4521274a27 | ||
|
|
dd8d31633f | ||
|
|
73c4b169c5 | ||
|
|
4d9a0cf228 | ||
|
|
da4e597f73 |
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1306
-20
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -9,7 +9,10 @@
|
||||
"abode-migrate": "dist/bin/abode-migrate.cjs",
|
||||
"abode-repl": "dist/bin/abode-repl.cjs",
|
||||
"abode-web": "dist/bin/abode-web.cjs",
|
||||
"abode-tui": "dist/bin/abode-tui.cjs"
|
||||
"abode-tui": "dist/bin/abode-tui.cjs",
|
||||
"abode-export": "dist/bin/abode-export.cjs",
|
||||
"abode-import": "dist/bin/abode-import.cjs",
|
||||
"abode-inspect": "dist/bin/abode-inspect.cjs"
|
||||
},
|
||||
"scripts": {
|
||||
"repl": "tsx --import ./src/meta/dev/register.ts",
|
||||
@@ -18,8 +21,19 @@
|
||||
"abode-web": "tsx --import ./src/meta/dev/register.ts --import ./src/meta/dev/webhot.ts src/bin/abode-web.ts",
|
||||
"abode-tui": "tsx --import ./src/meta/dev/register.ts --import ./src/meta/dev/silenthot.ts src/bin/abode-tui.ts",
|
||||
"abode-sources": "tsx --import ./src/meta/dev/register.ts src/bin/abode-sources.ts",
|
||||
"abode-export": "tsx --import ./src/meta/dev/register.ts src/bin/abode-export.ts",
|
||||
"abode-import": "tsx --import ./src/meta/dev/register.ts src/bin/abode-import.ts",
|
||||
"abode-inspect": "tsx --import ./src/meta/dev/register.ts src/bin/abode-inspect.ts",
|
||||
"build": "NODE_ENV=production npm run build:impl",
|
||||
"build:impl": "rm -rf dist && tsx node_modules/.bin/webpack && chmod +x dist/bin/* && chmod -x dist/bin/*.*"
|
||||
"build:impl": "rm -rf dist && tsx node_modules/.bin/webpack && chmod +x dist/bin/* && chmod -x dist/bin/*.*",
|
||||
"test": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test -name '*.test.ts' | sort)",
|
||||
"test:backends": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name '*.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)",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
||||
"lint": "eslint .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/bodyparser": "^6.0.0",
|
||||
@@ -38,20 +52,25 @@
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/koa": "^3.0.0",
|
||||
"@types/koa__router": "^12.0.4",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"dynohot": "^2.1.1",
|
||||
"eslint": "^9.39.5",
|
||||
"mini-css-extract-plugin": "^2.9.4",
|
||||
"prettier": "^3.6.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"scss-loader": "^0.0.1",
|
||||
"ts-loader": "^9.5.4",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"val-loader": "^6.0.0",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { getDbInterface } from "../db/index.js";
|
||||
import { isExportable, type ExportFilter } from "../db/types/ExportImport.js";
|
||||
import { isExportKind } from "../db/export/filter.js";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function printUsage(err: boolean | string = false): never {
|
||||
const log = (err ? console.error : console.log).bind(console);
|
||||
if (typeof err === "string") {
|
||||
log(`Error: ${err}`);
|
||||
log("");
|
||||
}
|
||||
log("Usage:");
|
||||
log("\tabode-export --help");
|
||||
log(
|
||||
"\tabode-export <database-url> [--kinds=user,abode,...] [--exclude-kinds=...] \\",
|
||||
);
|
||||
log("\t [--abodes=aid,...] [--users=uid,...] [--out=file|-]");
|
||||
process.exit(err ? 1 : 0);
|
||||
}
|
||||
|
||||
if (["-h", "--help", "help"].some((x) => args.includes(x))) printUsage();
|
||||
|
||||
let url: string | undefined;
|
||||
const flags = new Map<string, string>();
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const eq = arg.indexOf("=");
|
||||
if (eq === -1) printUsage(`invalid flag: ${arg}`);
|
||||
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
|
||||
} else if (url === undefined) {
|
||||
url = arg;
|
||||
} else {
|
||||
printUsage("too many arguments");
|
||||
}
|
||||
}
|
||||
if (!url) printUsage("missing <database-url>");
|
||||
|
||||
const knownFlags = ["kinds", "exclude-kinds", "abodes", "users", "out"];
|
||||
for (const key of flags.keys()) {
|
||||
if (!knownFlags.includes(key)) printUsage(`unknown flag: --${key}`);
|
||||
}
|
||||
|
||||
function parseList(value: string | undefined): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return value.split(",").filter(Boolean);
|
||||
}
|
||||
function parseKinds(value: string | undefined) {
|
||||
const list = parseList(value);
|
||||
if (!list) return undefined;
|
||||
const bad = list.filter((k) => !isExportKind(k));
|
||||
if (bad.length) printUsage(`invalid kind(s): ${bad.join(", ")}`);
|
||||
return list.filter(isExportKind);
|
||||
}
|
||||
|
||||
const filter: ExportFilter = {};
|
||||
const kinds = parseKinds(flags.get("kinds"));
|
||||
if (kinds) filter.kinds = kinds;
|
||||
const excludeKinds = parseKinds(flags.get("exclude-kinds"));
|
||||
if (excludeKinds) filter.excludeKinds = excludeKinds;
|
||||
const abodes = parseList(flags.get("abodes"));
|
||||
if (abodes) filter.abodes = abodes;
|
||||
const users = parseList(flags.get("users"));
|
||||
if (users) filter.users = users;
|
||||
|
||||
const db = await getDbInterface(url);
|
||||
if (!isExportable(db)) {
|
||||
console.error(`Error: backend '${db.name}' does not support export`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const out = flags.get("out") ?? "-";
|
||||
const dest =
|
||||
out === "-" ? process.stdout : createWriteStream(out, { encoding: "utf8" });
|
||||
|
||||
const ac = new AbortController();
|
||||
const onSignal = () => ac.abort();
|
||||
process.on("SIGINT", onSignal);
|
||||
process.on("SIGTERM", onSignal);
|
||||
|
||||
try {
|
||||
await pipeline(db.export({ filter, signal: ac.signal }), dest);
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) {
|
||||
console.error("Export aborted");
|
||||
process.exit(130);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
process.off("SIGINT", onSignal);
|
||||
process.off("SIGTERM", onSignal);
|
||||
await db.close().catch(() => {});
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { getDbInterface } from "../db/index.js";
|
||||
import { isExportKind } from "../db/export/filter.js";
|
||||
import { isImportable, type ExportFilter } from "../db/types/ExportImport.js";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function printUsage(err: boolean | string = false): never {
|
||||
const log = (err ? console.error : console.log).bind(console);
|
||||
if (typeof err === "string") {
|
||||
log(`Error: ${err}`);
|
||||
log("");
|
||||
}
|
||||
log("Usage:");
|
||||
log("\tabode-import --help");
|
||||
log("\tabode-import <database-url> <input-file|-> [--kinds=...] \\");
|
||||
log(
|
||||
"\t [--exclude-kinds=...] [--abodes=aid,...] [--users=uid,...]",
|
||||
);
|
||||
log("");
|
||||
log(
|
||||
"The target must be a local backend (sqlite or postgres), already migrated",
|
||||
);
|
||||
log("(run abode-migrate first). Remote (api) targets are not importable.");
|
||||
process.exit(err ? 1 : 0);
|
||||
}
|
||||
|
||||
if (["-h", "--help", "help"].some((x) => args.includes(x))) printUsage();
|
||||
|
||||
const positional: string[] = [];
|
||||
const flags = new Map<string, string>();
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const eq = arg.indexOf("=");
|
||||
if (eq === -1) printUsage(`invalid flag: ${arg}`);
|
||||
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const url = positional[0];
|
||||
const input = positional[1];
|
||||
if (!url) printUsage("missing <database-url>");
|
||||
if (!input) printUsage("missing <input-file|->");
|
||||
if (positional.length > 2) printUsage("too many arguments");
|
||||
|
||||
const knownFlags = ["kinds", "exclude-kinds", "abodes", "users"];
|
||||
for (const key of flags.keys()) {
|
||||
if (!knownFlags.includes(key)) printUsage(`unknown flag: --${key}`);
|
||||
}
|
||||
|
||||
function parseList(value: string | undefined): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return value.split(",").filter(Boolean);
|
||||
}
|
||||
function parseKinds(value: string | undefined) {
|
||||
const list = parseList(value);
|
||||
if (!list) return undefined;
|
||||
const bad = list.filter((k) => !isExportKind(k));
|
||||
if (bad.length) printUsage(`invalid kind(s): ${bad.join(", ")}`);
|
||||
return list.filter(isExportKind);
|
||||
}
|
||||
|
||||
const filter: ExportFilter = {};
|
||||
const kinds = parseKinds(flags.get("kinds"));
|
||||
if (kinds) filter.kinds = kinds;
|
||||
const excludeKinds = parseKinds(flags.get("exclude-kinds"));
|
||||
if (excludeKinds) filter.excludeKinds = excludeKinds;
|
||||
const abodes = parseList(flags.get("abodes"));
|
||||
if (abodes) filter.abodes = abodes;
|
||||
const users = parseList(flags.get("users"));
|
||||
if (users) filter.users = users;
|
||||
|
||||
// Resolve the backend generically. Import lives on the local backends (sqlite,
|
||||
// postgres); the remote (api) interface has no `import`, so `isImportable`
|
||||
// keeps it from ever running over HTTP.
|
||||
const db = await getDbInterface(url);
|
||||
if (!isImportable(db)) {
|
||||
console.error(`Error: backend '${db.name}' does not support import`);
|
||||
await db.close().catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const source = input === "-" ? process.stdin : createReadStream(input);
|
||||
|
||||
const ac = new AbortController();
|
||||
const onSignal = () => ac.abort();
|
||||
process.on("SIGINT", onSignal);
|
||||
process.on("SIGTERM", onSignal);
|
||||
|
||||
try {
|
||||
const result = await db.import(source, { filter, signal: ac.signal });
|
||||
const total = Object.values(result.counts).reduce((a, b) => a + b, 0);
|
||||
console.log(`Imported ${total} record(s):`);
|
||||
for (const [kind, count] of Object.entries(result.counts)) {
|
||||
console.log(`- ${kind}: ${count}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) {
|
||||
console.error("Import aborted; no changes committed");
|
||||
process.exit(130);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
process.off("SIGINT", onSignal);
|
||||
process.off("SIGTERM", onSignal);
|
||||
await db.close().catch(() => {});
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { inspectExportStream } from "../db/export/inspect.js";
|
||||
import { isExportKind } from "../db/export/filter.js";
|
||||
import type { ExportKind } from "../db/types/ExportImport.js";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function printUsage(err: boolean | string = false): never {
|
||||
const log = (err ? console.error : console.log).bind(console);
|
||||
if (typeof err === "string") {
|
||||
log(`Error: ${err}`);
|
||||
log("");
|
||||
}
|
||||
log("Usage:");
|
||||
log("\tabode-inspect --help");
|
||||
log("\tabode-inspect <input-file|-> [--stop-after=note,...]");
|
||||
process.exit(err ? 1 : 0);
|
||||
}
|
||||
|
||||
if (["-h", "--help", "help"].some((x) => args.includes(x))) printUsage();
|
||||
|
||||
let input: string | undefined;
|
||||
const flags = new Map<string, string>();
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const eq = arg.indexOf("=");
|
||||
if (eq === -1) printUsage(`invalid flag: ${arg}`);
|
||||
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
|
||||
} else if (input === undefined) {
|
||||
input = arg;
|
||||
} else {
|
||||
printUsage("too many arguments");
|
||||
}
|
||||
}
|
||||
if (!input) printUsage("missing <input-file|->");
|
||||
|
||||
for (const key of flags.keys()) {
|
||||
if (key !== "stop-after") printUsage(`unknown flag: --${key}`);
|
||||
}
|
||||
|
||||
let stopAfterKinds: ExportKind[] | undefined;
|
||||
const rawStop = flags.get("stop-after");
|
||||
if (rawStop !== undefined) {
|
||||
const list = rawStop.split(",").filter(Boolean);
|
||||
const bad = list.filter((k) => !isExportKind(k));
|
||||
if (bad.length) printUsage(`invalid kind(s): ${bad.join(", ")}`);
|
||||
stopAfterKinds = list.filter(isExportKind);
|
||||
}
|
||||
|
||||
const source = input === "-" ? process.stdin : createReadStream(input);
|
||||
|
||||
const { counts, meta } = await inspectExportStream(source, { stopAfterKinds });
|
||||
|
||||
if (meta) {
|
||||
console.log("Meta:");
|
||||
console.log(`- format version: ${meta.v ?? "?"}`);
|
||||
console.log(`- exported at: ${meta.exportedAt ?? "?"}`);
|
||||
console.log(`- source backend: ${meta.source ?? "?"}`);
|
||||
if (meta.filter && Object.keys(meta.filter).length) {
|
||||
console.log(`- effective filter: ${JSON.stringify(meta.filter)}`);
|
||||
}
|
||||
}
|
||||
console.log("Counts:");
|
||||
const entries = Object.entries(counts);
|
||||
if (!entries.length) console.log("(none)");
|
||||
for (const [kind, count] of entries) console.log(`- ${kind}: ${count}`);
|
||||
|
||||
process.exit(0);
|
||||
@@ -40,7 +40,7 @@ switch (cmd) {
|
||||
if (!current.length) console.log("(none)");
|
||||
for (const migration of current) {
|
||||
console.log(
|
||||
`- ${migration.id} (${migration.name}) applied at ${migration.applied_at}`
|
||||
`- ${migration.id} (${migration.name}) applied at ${migration.applied_at}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -73,7 +73,7 @@ switch (cmd) {
|
||||
password: await hashPassword("changeme"),
|
||||
});
|
||||
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();
|
||||
@@ -88,7 +88,7 @@ switch (cmd) {
|
||||
expires_at: null,
|
||||
});
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ if (args.length > 1) printUsage("too many arguments");
|
||||
|
||||
console.log(
|
||||
`Compiled with ${compiledSources.length} sources:`,
|
||||
compiledSources.join(", ")
|
||||
compiledSources.join(", "),
|
||||
);
|
||||
|
||||
const url = args[0] ?? "abode://";
|
||||
@@ -31,7 +31,7 @@ for (const source of sources) {
|
||||
console.log(`- ${source.name}`);
|
||||
console.log(
|
||||
" - protocols:",
|
||||
source.protocols.map((x) => `'${x}'`).join(" ")
|
||||
source.protocols.map((x) => `'${x}'`).join(" "),
|
||||
);
|
||||
const match = source.checkUrl(url);
|
||||
console.log(` - matches url: ${match}`);
|
||||
@@ -41,7 +41,7 @@ for (const source of sources) {
|
||||
console.log(
|
||||
` - generates an interface named ${db.name} ${
|
||||
db.backend ? "with" : "without"
|
||||
} backend`
|
||||
} backend`,
|
||||
);
|
||||
await db.close().catch(console.error);
|
||||
} catch (e) {
|
||||
@@ -53,7 +53,7 @@ for (const source of sources) {
|
||||
console.log(
|
||||
` - generates a migrator knowing ${
|
||||
db.listAvailableMigrations().length
|
||||
} migrations`
|
||||
} migrations`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -30,7 +30,7 @@ const bgColor = await new Promise<string>((ok, ko) => {
|
||||
process.stdin.once("data", (chunk) => {
|
||||
const result = chunk.toString("utf8");
|
||||
const match = result.match(
|
||||
/^\u001b]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)$/
|
||||
/^\u001b]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)$/,
|
||||
);
|
||||
if (!match) return ko("Didn't recognize terminal bg color");
|
||||
const [r, g, b] = match
|
||||
@@ -78,7 +78,7 @@ if (import.meta.hot) {
|
||||
|
||||
import.meta.hot.accept("../tui/App.js", (mod) => {
|
||||
fullscreenApp.instance.rerender(
|
||||
(mod.app as typeof app)({ db, bgColor, store })
|
||||
(mod.app as typeof app)({ db, bgColor, store }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+68
-14
@@ -25,8 +25,11 @@ import type {
|
||||
CreateUser,
|
||||
UpdateUser,
|
||||
} from "../types/User.js";
|
||||
import { Readable } from "node:stream";
|
||||
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
||||
import type { Exportable, ExportOptions } from "../types/ExportImport.js";
|
||||
|
||||
export class ApiInterface implements DbInterface {
|
||||
export class ApiInterface implements DbInterface, Exportable {
|
||||
#root: string;
|
||||
#headers: Record<string, string>;
|
||||
#readonly: boolean;
|
||||
@@ -39,7 +42,7 @@ export class ApiInterface implements DbInterface {
|
||||
}: {
|
||||
headers?: Record<string, string>;
|
||||
readonly?: boolean;
|
||||
} = {}
|
||||
} = {},
|
||||
) {
|
||||
if (root.endsWith("/")) root = root.slice(0, -1);
|
||||
this.#root = root;
|
||||
@@ -55,7 +58,7 @@ export class ApiInterface implements DbInterface {
|
||||
.map((part) => {
|
||||
if (part.startsWith(":")) {
|
||||
const value = remaining.get(part.slice(1));
|
||||
remaining.delete(part);
|
||||
remaining.delete(part.slice(1));
|
||||
if (value === undefined)
|
||||
throw new Error(`Missing ${part} in params`);
|
||||
return encodeURIComponent(value);
|
||||
@@ -82,7 +85,7 @@ export class ApiInterface implements DbInterface {
|
||||
params?: Record<string, string>;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const resolvedHeaders = { ...this.#headers, ...headers };
|
||||
if (body !== undefined) {
|
||||
@@ -113,6 +116,7 @@ export class ApiInterface implements DbInterface {
|
||||
throw new Error(`${res.status} ${res.statusText} ${text}`);
|
||||
}
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -246,7 +250,7 @@ export class ApiInterface implements DbInterface {
|
||||
});
|
||||
}
|
||||
async createApikey(
|
||||
apikey: CreateApikey
|
||||
apikey: CreateApikey,
|
||||
): Promise<[ClientApikey, `at_${string}`]> {
|
||||
this.#checkReadonly();
|
||||
const { apikey: key, token } = await this.#call<{
|
||||
@@ -266,24 +270,74 @@ export class ApiInterface implements DbInterface {
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/notes");
|
||||
}
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/notes/:nid", { params: { nid } });
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
throw new Error("Unimplemented");
|
||||
this.#checkReadonly();
|
||||
await this.#call("DELETE", "/notes/:nid", { params: { nid } });
|
||||
}
|
||||
async createNote(note: CreateNote): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
async createNote(note: CreateNote, _ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("POST", "/abodes/:aid/notes", {
|
||||
params: { aid: note.aid },
|
||||
body: note,
|
||||
});
|
||||
}
|
||||
async updateNote(note: UpdateNote): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
async updateNote(note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("PATCH", "/notes/:nid", {
|
||||
params: { nid: note.nid },
|
||||
body: note,
|
||||
});
|
||||
}
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/abodes/:aid/notes", { params: { aid } });
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/users/:uid/notes", { params: { uid } });
|
||||
}
|
||||
|
||||
export(options: ExportOptions = {}): NodeJS.ReadableStream {
|
||||
// Streaming NDJSON bypasses the JSON-only `#call` helper: the raw
|
||||
// `fetch` gets the caller's `signal` directly, so aborting cancels the
|
||||
// underlying HTTP request itself.
|
||||
const { filter, signal } = options;
|
||||
const sp = new URLSearchParams();
|
||||
if (filter?.kinds) sp.set("kinds", filter.kinds.join(","));
|
||||
if (filter?.excludeKinds)
|
||||
sp.set("excludeKinds", filter.excludeKinds.join(","));
|
||||
if (filter?.abodes) sp.set("abodes", filter.abodes.join(","));
|
||||
if (filter?.users) sp.set("users", filter.users.join(","));
|
||||
const query = sp.toString();
|
||||
const url = this.#root + "/export" + (query ? `?${query}` : "");
|
||||
const headers = { ...this.#headers };
|
||||
|
||||
async function* generate(): AsyncGenerator<Buffer | Uint8Array> {
|
||||
const res = await fetch(url, { method: "GET", headers, signal });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
switch (res.status) {
|
||||
case 400:
|
||||
throw new InvalidAbodeError();
|
||||
case 401:
|
||||
throw new NotAuthorizedAbodeError();
|
||||
case 403:
|
||||
throw new ReadonlyAbodeError();
|
||||
case 404:
|
||||
throw new NotFoundAbodeError();
|
||||
case 409:
|
||||
throw new ConflictAbodeError();
|
||||
default:
|
||||
throw new Error(`${res.status} ${res.statusText} ${text}`);
|
||||
}
|
||||
}
|
||||
if (!res.body) return;
|
||||
yield* Readable.fromWeb(res.body as WebReadableStream<Uint8Array>);
|
||||
}
|
||||
|
||||
return Readable.from(generate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { apiProtocols } from "./url.js";
|
||||
|
||||
const getApi = () =>
|
||||
import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then(
|
||||
(x) => x.default
|
||||
(x) => x.default,
|
||||
);
|
||||
|
||||
const getApiDynamic: GetDbDynamic = {
|
||||
|
||||
+4
-2
@@ -17,7 +17,9 @@ export function parseApiUrl(url: string) {
|
||||
urlObj.href = urlObj.href.replace(/^abode\+/, "");
|
||||
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
|
||||
const headers = Object.fromEntries(
|
||||
[...urlObj.searchParams.entries()].filter(([param]) => param !== "readonly")
|
||||
[...urlObj.searchParams.entries()].filter(
|
||||
([param]) => param !== "readonly",
|
||||
),
|
||||
);
|
||||
if (urlObj.username) {
|
||||
headers["Authorization"] =
|
||||
@@ -26,7 +28,7 @@ export function parseApiUrl(url: string) {
|
||||
[
|
||||
decodeURIComponent(urlObj.username),
|
||||
decodeURIComponent(urlObj.password),
|
||||
].join(":")
|
||||
].join(":"),
|
||||
);
|
||||
urlObj.username = "";
|
||||
urlObj.password = "";
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||
.catch(() => null)
|
||||
.then((dbSource) => {
|
||||
if (dbSource) dbSources.push(dbSource);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GetDbStatic } from "./types/GetDb.js";
|
||||
let rawGetDbSources: typeof getDbSources | undefined = undefined;
|
||||
const getGetDbSources = () =>
|
||||
import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then(
|
||||
(x) => x.getDbSources
|
||||
(x) => x.getDbSources,
|
||||
);
|
||||
|
||||
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import getApiStatic from "./api/getdb.static.js";
|
||||
import getPgStatic from "./postgres/getdb.static.js";
|
||||
import getSqliteStatic from "./sqlite/getdb.static.js";
|
||||
import type { GetDbStatic } from "./types/GetDb.js";
|
||||
|
||||
const dbSources: GetDbStatic[] = [getSqliteStatic, getApiStatic];
|
||||
const dbSources: GetDbStatic[] = [getSqliteStatic, getPgStatic, getApiStatic];
|
||||
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||
void url;
|
||||
return dbSources;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
EXPORT_KIND_ORDER,
|
||||
type ExportFilter,
|
||||
type ExportKind,
|
||||
} from "../types/ExportImport.js";
|
||||
|
||||
const EXPORT_KINDS = new Set<ExportKind>(EXPORT_KIND_ORDER);
|
||||
|
||||
export function isExportKind(x: unknown): x is ExportKind {
|
||||
return typeof x === "string" && EXPORT_KINDS.has(x as ExportKind);
|
||||
}
|
||||
|
||||
/** Whether a `kind` survives a filter's `kinds`/`excludeKinds` rules. */
|
||||
export function kindAllowed(
|
||||
filter: ExportFilter | undefined,
|
||||
kind: ExportKind,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.kinds && !filter.kinds.includes(kind)) return false;
|
||||
if (filter.excludeKinds && filter.excludeKinds.includes(kind)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an individual record passes a filter's `abodes`/`users`/`apikeys`
|
||||
* allowlists. `abodes` scopes abode/resident/note (by aid); `users` scopes user
|
||||
* (by uid); `apikey` records are scoped by `apikeys` when present, else by
|
||||
* `users`. An absent allowlist means "unrestricted".
|
||||
*/
|
||||
export function recordAllowed(
|
||||
filter: ExportFilter | undefined,
|
||||
kind: ExportKind,
|
||||
record: { uid?: string; aid?: string },
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
switch (kind) {
|
||||
case "user":
|
||||
return !filter.users || filter.users.includes(record.uid as string);
|
||||
case "apikey": {
|
||||
const allow = filter.apikeys ?? filter.users;
|
||||
return !allow || allow.includes(record.uid as string);
|
||||
}
|
||||
case "abode":
|
||||
case "resident":
|
||||
case "note":
|
||||
return !filter.abodes || filter.abodes.includes(record.aid as string);
|
||||
}
|
||||
}
|
||||
|
||||
function intersectList<T extends string>(a?: T[], b?: T[]): T[] | undefined {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
const bs = new Set(b);
|
||||
return a.filter((x) => bs.has(x));
|
||||
}
|
||||
|
||||
function unionList<T extends string>(a?: T[], b?: T[]): T[] | undefined {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return [...new Set([...a, ...b])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine two filters as a hard intersection: the result can never permit more
|
||||
* than either input. An absent allowlist is treated as "unrestricted", so
|
||||
* intersecting it with a present one yields the present one. `excludeKinds`
|
||||
* are unioned (either exclusion still excludes).
|
||||
*/
|
||||
export function intersectExportFilters(
|
||||
a: ExportFilter | null | undefined,
|
||||
b: ExportFilter | null | undefined,
|
||||
): ExportFilter {
|
||||
if (!a) return b ?? {};
|
||||
if (!b) return a;
|
||||
return {
|
||||
kinds: intersectList(a.kinds, b.kinds),
|
||||
excludeKinds: unionList(a.excludeKinds, b.excludeKinds),
|
||||
abodes: intersectList(a.abodes, b.abodes),
|
||||
users: intersectList(a.users, b.users),
|
||||
apikeys: intersectList(a.apikeys, b.apikeys),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import readline from "node:readline";
|
||||
import type {
|
||||
ExportFilter,
|
||||
ExportKind,
|
||||
ExportMeta,
|
||||
} from "../types/ExportImport.js";
|
||||
import { isExportKind } from "./filter.js";
|
||||
|
||||
export type InspectResult = {
|
||||
counts: Partial<Record<ExportKind, number>>;
|
||||
meta?: Partial<ExportMeta> & { filter?: ExportFilter };
|
||||
};
|
||||
|
||||
/**
|
||||
* Tally the kinds/counts contained in an NDJSON export stream without ever
|
||||
* touching a database. Works on any stream — a file, {@link ApiInterface}'s
|
||||
* export, or a pipe straight from {@link SqliteInterface}'s export.
|
||||
*
|
||||
* If `stopAfterKinds` is given, reading stops as soon as at least one record of
|
||||
* every requested kind has been seen, rather than draining to EOF — useful for
|
||||
* probing large dumps ("does this contain notes at all?").
|
||||
*/
|
||||
export async function inspectExportStream(
|
||||
source: NodeJS.ReadableStream,
|
||||
options: { signal?: AbortSignal; stopAfterKinds?: ExportKind[] } = {},
|
||||
): Promise<InspectResult> {
|
||||
const { signal, stopAfterKinds } = options;
|
||||
const counts: Partial<Record<ExportKind, number>> = {};
|
||||
let meta: InspectResult["meta"];
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: source,
|
||||
crlfDelay: Infinity,
|
||||
signal,
|
||||
});
|
||||
try {
|
||||
for await (const raw of rl) {
|
||||
signal?.throwIfAborted();
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
let parsed: { kind?: unknown; data?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed.kind === "meta") {
|
||||
meta = parsed.data as InspectResult["meta"];
|
||||
continue;
|
||||
}
|
||||
if (!isExportKind(parsed.kind)) continue;
|
||||
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
|
||||
if (stopAfterKinds && stopAfterKinds.every((k) => counts[k])) break;
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
|
||||
return { counts, meta };
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import type { BackendDbInterface } from "../types/DbInterface.js";
|
||||
import {
|
||||
isValidUserPassword,
|
||||
type ClientUser,
|
||||
type CreateUser,
|
||||
type LoginUser,
|
||||
type UpdateUser,
|
||||
type UserFlags,
|
||||
} from "../types/User.js";
|
||||
import { pgToClientUser, pgToDate } from "./cast.js";
|
||||
import {
|
||||
ConflictAbodeError,
|
||||
InvalidAbodeError,
|
||||
NotAuthorizedAbodeError,
|
||||
NotFoundAbodeError,
|
||||
ReadonlyAbodeError,
|
||||
} from "../types/errors.js";
|
||||
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
||||
import type {
|
||||
CreateResident,
|
||||
Resident,
|
||||
ResidentFlags,
|
||||
updateResident,
|
||||
} from "../types/Resident.js";
|
||||
import { validatePassword } from "../../util/hash.js";
|
||||
import { createApikeyToken, createSessionToken } from "../../util/token.js";
|
||||
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
||||
import { calcUpdates, catSql, joinSql, sql } from "./sql.js";
|
||||
import {
|
||||
selectAbode,
|
||||
selectAbodes,
|
||||
selectClientApikey,
|
||||
selectClientApikeys,
|
||||
selectClientUser,
|
||||
selectClientUsers,
|
||||
selectResident,
|
||||
selectResidents,
|
||||
selectNote,
|
||||
selectNotes,
|
||||
selectPartialNotes,
|
||||
} from "./query.js";
|
||||
import type {
|
||||
CreateNote,
|
||||
Note,
|
||||
PartialNote,
|
||||
UpdateNote,
|
||||
} from "../types/Note.js";
|
||||
import type { WrappedPgClient } from "./pool.js";
|
||||
import { Readable } from "node:stream";
|
||||
import readline from "node:readline";
|
||||
import {
|
||||
EXPORT_KIND_ORDER,
|
||||
type Exportable,
|
||||
type ExportKind,
|
||||
type ExportOptions,
|
||||
type Importable,
|
||||
type ImportOptions,
|
||||
type ImportResult,
|
||||
} from "../types/ExportImport.js";
|
||||
import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js";
|
||||
|
||||
export class PostgresInterface
|
||||
implements BackendDbInterface, Exportable, Importable
|
||||
{
|
||||
#db: WrappedPgClient;
|
||||
|
||||
constructor(db: WrappedPgClient) {
|
||||
this.#db = db;
|
||||
}
|
||||
|
||||
#checkReadonly(): void {
|
||||
if (this.#db.readonly) throw new ReadonlyAbodeError();
|
||||
}
|
||||
|
||||
get _() {
|
||||
return {
|
||||
sql,
|
||||
catSql,
|
||||
joinSql,
|
||||
db: this.#db,
|
||||
};
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this.#db.readonly;
|
||||
}
|
||||
get backend(): true {
|
||||
return true;
|
||||
}
|
||||
get name(): "postgres" {
|
||||
return "postgres";
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.#db.destroy();
|
||||
}
|
||||
|
||||
async listUsers(): Promise<ClientUser[]> {
|
||||
return selectClientUsers(this.#db);
|
||||
}
|
||||
async #getUserById(uid: string, db: WrappedPgClient): Promise<ClientUser> {
|
||||
const user = await selectClientUser(db, sql`u."uid" = ${{ uuid: uid }}`);
|
||||
if (!user) throw new NotFoundAbodeError();
|
||||
return user;
|
||||
}
|
||||
async getUserById(id: string): Promise<ClientUser> {
|
||||
return this.#getUserById(id, this.#db);
|
||||
}
|
||||
async getUserByEmail(email: string): Promise<ClientUser> {
|
||||
const user = await selectClientUser(
|
||||
this.#db,
|
||||
sql`u."email" = ${{ text: email }}`,
|
||||
);
|
||||
if (!user) throw new NotFoundAbodeError();
|
||||
return user;
|
||||
}
|
||||
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
|
||||
const rawUser = await this.#db.get<{
|
||||
uid: string;
|
||||
email: string;
|
||||
name: string;
|
||||
flags: unknown;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
password: string;
|
||||
}>(
|
||||
sql`
|
||||
SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password"
|
||||
FROM "users"
|
||||
WHERE "email" = ${{ text: email }}
|
||||
`,
|
||||
);
|
||||
if (!rawUser) throw new NotFoundAbodeError();
|
||||
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
|
||||
if (!(await validatePassword(password, rawUser.password)))
|
||||
throw new NotAuthorizedAbodeError();
|
||||
return pgToClientUser(rawUser);
|
||||
}
|
||||
async deleteUserById(id: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = await this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "users"
|
||||
WHERE "uid" = ${{ uuid: id }}
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createUser(user: CreateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
|
||||
const uid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
await tx.run(
|
||||
sql`
|
||||
INSERT INTO "users"("uid", "email", "name", "password", "flags")
|
||||
VALUES(
|
||||
${{ uuid: uid }},
|
||||
${{ text: user.email }},
|
||||
${{ text: user.name }},
|
||||
${{ text: user.password }},
|
||||
${{ jsonb: user.flags }}
|
||||
)
|
||||
`,
|
||||
);
|
||||
return this.#getUserById(uid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
const updates = [];
|
||||
if ("email" in user && user.email !== undefined)
|
||||
updates.push(sql`"email" = ${{ text: user.email }}`);
|
||||
if ("name" in user && user.name !== undefined)
|
||||
updates.push(sql`"name" = ${{ text: user.name }}`);
|
||||
if ("password" in user && user.password !== undefined) {
|
||||
if (!isValidUserPassword(user.password))
|
||||
throw new InvalidAbodeError();
|
||||
if (user.password.startsWith("#")) {
|
||||
await tx.run(sql`
|
||||
DELETE FROM "apikeys"
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`);
|
||||
}
|
||||
await tx.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`);
|
||||
updates.push(sql`"password" = ${{ text: user.password }}`);
|
||||
}
|
||||
if ("flags" in user && user.flags !== undefined)
|
||||
updates.push(sql`"flags" = ${{ jsonb: user.flags as UserFlags }}`);
|
||||
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
const { changes } = await tx.run(
|
||||
sql`
|
||||
UPDATE "users"
|
||||
SET
|
||||
"updated_at" = NOW(),
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getUserById(user.uid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listAbodes(): Promise<Abode[]> {
|
||||
return selectAbodes(this.#db);
|
||||
}
|
||||
async #getAbodeById(aid: string, db: WrappedPgClient): Promise<Abode> {
|
||||
const abode = await selectAbode(db, sql`a."aid" = ${{ uuid: aid }}`);
|
||||
if (!abode) throw new NotFoundAbodeError();
|
||||
return abode;
|
||||
}
|
||||
async getAbodeById(id: string): Promise<Abode> {
|
||||
return this.#getAbodeById(id, this.#db);
|
||||
}
|
||||
async deleteAbodeById(id: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = await this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "abodes"
|
||||
WHERE "aid" = ${{ uuid: id }}
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
const aid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
await tx.run(
|
||||
sql`
|
||||
INSERT INTO "abodes"("aid", "name", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: aid }},
|
||||
${{ text: abode.name }},
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`,
|
||||
);
|
||||
return this.#getAbodeById(aid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
})(abode);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
const { changes } = await tx.run(
|
||||
sql`
|
||||
UPDATE "abodes"
|
||||
SET
|
||||
"updated_at" = NOW(),
|
||||
"updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "aid" = ${{ uuid: abode.aid }}
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getAbodeById(abode.aid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listResidents(): Promise<Resident[]> {
|
||||
return selectResidents(this.#db);
|
||||
}
|
||||
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
||||
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||
}
|
||||
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
||||
return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||
}
|
||||
async #getResidentById(
|
||||
uid: string,
|
||||
aid: string,
|
||||
db: WrappedPgClient,
|
||||
): Promise<Resident> {
|
||||
const resident = await selectResident(
|
||||
db,
|
||||
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
|
||||
);
|
||||
if (!resident) throw new NotFoundAbodeError();
|
||||
return resident;
|
||||
}
|
||||
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
||||
return this.#getResidentById(uid, aid, this.#db);
|
||||
}
|
||||
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = await this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "residents"
|
||||
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createResident(
|
||||
resident: CreateResident,
|
||||
ctx: { uid: string },
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
await tx.run(
|
||||
sql`
|
||||
INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: resident.uid }},
|
||||
${{ uuid: resident.aid }},
|
||||
${{ jsonb: resident.flags }},
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`,
|
||||
);
|
||||
return this.#getResidentById(resident.uid, resident.aid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateResident(
|
||||
resident: updateResident,
|
||||
ctx: { uid: string },
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||
})(resident);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
const { changes } = await tx.run(
|
||||
sql`
|
||||
UPDATE "residents"
|
||||
SET
|
||||
"updated_at" = NOW(),
|
||||
"updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE
|
||||
"uid" = ${{ uuid: resident.uid }}
|
||||
AND "aid" = ${{ uuid: resident.aid }}
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getResidentById(resident.uid, resident.aid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listUsersByAbodeId(id: string): Promise<ClientUser[]> {
|
||||
return selectClientUsers(
|
||||
this.#db,
|
||||
sql`
|
||||
JOIN "residents" r ON u."uid" = r."uid"
|
||||
WHERE r."aid" = ${{ uuid: id }}
|
||||
`,
|
||||
);
|
||||
}
|
||||
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
||||
return selectAbodes(
|
||||
this.#db,
|
||||
sql`
|
||||
JOIN "residents" r ON a."aid" = r."aid"
|
||||
WHERE r."uid" = ${{ uuid: id }}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
|
||||
const session = await this.#db.get<{
|
||||
uid: string;
|
||||
expires_at: Date;
|
||||
}>(sql`
|
||||
SELECT "uid", "expires_at"
|
||||
FROM "sessions"
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
if (!session) throw new NotFoundAbodeError();
|
||||
if (new Date(pgToDate(session.expires_at)).getTime() < Date.now()) {
|
||||
if (!this.readonly) {
|
||||
await this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "expires_at" < NOW()
|
||||
`);
|
||||
}
|
||||
throw new NotFoundAbodeError();
|
||||
}
|
||||
if (!this.readonly) {
|
||||
await this.#db.run(sql`
|
||||
UPDATE "sessions"
|
||||
SET "expires_at" = NOW() + INTERVAL '7 days'
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
}
|
||||
return this.#getUserById(session.uid, this.#db);
|
||||
}
|
||||
async createSession(uid: string): Promise<`as_${string}`> {
|
||||
this.#checkReadonly();
|
||||
const token = createSessionToken();
|
||||
await this.#db.run(sql`
|
||||
INSERT INTO "sessions"("uid", "token")
|
||||
VALUES(${{ uuid: uid }}, ${{ text: token }})
|
||||
`);
|
||||
return token;
|
||||
}
|
||||
async deleteSessionsByUser(uid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
await this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "uid" = ${{ uuid: uid }}
|
||||
`);
|
||||
}
|
||||
|
||||
async deleteSession(token: `as_${string}`): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
await this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
}
|
||||
|
||||
async #getApikeyByToken(
|
||||
token: `at_${string}`,
|
||||
db: WrappedPgClient,
|
||||
): Promise<ClientApikey> {
|
||||
const apikey = await selectClientApikey(
|
||||
db,
|
||||
sql`k."token" = ${{ text: token }}`,
|
||||
);
|
||||
if (!apikey) throw new NotFoundAbodeError();
|
||||
return apikey;
|
||||
}
|
||||
async getUserByApikey(
|
||||
token: `at_${string}`,
|
||||
): Promise<[ClientUser, ClientApikey]> {
|
||||
const apikey = await this.#getApikeyByToken(token, this.#db);
|
||||
if (
|
||||
apikey.expires_at &&
|
||||
new Date(apikey.expires_at).getTime() < Date.now()
|
||||
) {
|
||||
throw new NotAuthorizedAbodeError();
|
||||
}
|
||||
return [await this.#getUserById(apikey.uid, this.#db), apikey];
|
||||
}
|
||||
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
||||
return selectClientApikeys(this.#db, sql`k."uid" = ${{ uuid: uid }}`);
|
||||
}
|
||||
async getApikeyById(kid: string): Promise<ClientApikey> {
|
||||
const apikey = await selectClientApikey(
|
||||
this.#db,
|
||||
sql`k."kid" = ${{ uuid: kid }}`,
|
||||
);
|
||||
if (!apikey) throw new NotFoundAbodeError();
|
||||
return apikey;
|
||||
}
|
||||
async createApikey(
|
||||
apikey: CreateApikey,
|
||||
): Promise<[ClientApikey, `at_${string}`]> {
|
||||
this.#checkReadonly();
|
||||
const token = createApikeyToken();
|
||||
const kid = crypto.randomUUID();
|
||||
let expires = apikey.expires_at;
|
||||
if (expires === undefined)
|
||||
expires = new Date(
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
|
||||
).toISOString();
|
||||
if (expires && new Date(expires).getTime() < Date.now())
|
||||
throw new InvalidAbodeError();
|
||||
|
||||
await this.#db.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: apikey.uid }},
|
||||
${{ uuid: kid }},
|
||||
${{ text: token }},
|
||||
${{ text: apikey.name }},
|
||||
${{ jsonb: apikey.permissions }},
|
||||
${expires ? { date: expires } : { null: true }}
|
||||
)
|
||||
`);
|
||||
return [await this.#getApikeyByToken(token, this.#db), token];
|
||||
}
|
||||
async deleteApikeyById(kid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = await this.#db.run(sql`
|
||||
DELETE FROM "apikeys"
|
||||
WHERE "kid" = ${{ uuid: kid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
return selectPartialNotes(this.#db);
|
||||
}
|
||||
async #getNoteById(nid: string, db: WrappedPgClient): Promise<Note> {
|
||||
const note = await selectNote(db, sql`n."nid" = ${{ uuid: nid }}`);
|
||||
if (!note) throw new NotFoundAbodeError();
|
||||
return note;
|
||||
}
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
return this.#getNoteById(nid, this.#db);
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = await this.#db.run(sql`
|
||||
DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
const nid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
await tx.run(sql`
|
||||
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: nid }}, ${{ uuid: note.aid }}, ${{ text: note.name }},
|
||||
${{ text: note.content ?? "" }}, ${{ jsonb: note.properties }},
|
||||
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }}
|
||||
)
|
||||
`);
|
||||
return this.#getNoteById(nid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
content: (value: string) => sql`"content" = ${{ text: value }}`,
|
||||
properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`,
|
||||
})(note);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
const { changes } = await tx.run(sql`
|
||||
UPDATE "notes"
|
||||
SET "updated_at" = NOW(), "updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "nid" = ${{ uuid: note.nid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getNoteById(note.nid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
|
||||
}
|
||||
|
||||
export(options: ExportOptions = {}): NodeJS.ReadableStream {
|
||||
const { filter, signal } = options;
|
||||
const db = this.#db;
|
||||
const source = this.name;
|
||||
|
||||
// Emission follows the FK-safe EXPORT_KIND_ORDER (part of the wire
|
||||
// contract; each `load()` is a single query, run lazily and skipped once
|
||||
// the destination aborts.
|
||||
const loaders: Partial<
|
||||
Record<ExportKind, () => Promise<{ uid?: string; aid?: string }[]>>
|
||||
> = {
|
||||
user: () => selectClientUsers(db),
|
||||
abode: () => selectAbodes(db),
|
||||
resident: () => selectResidents(db),
|
||||
apikey: () => selectClientApikeys(db),
|
||||
note: () => selectNotes(db),
|
||||
};
|
||||
|
||||
async function* generate(): AsyncGenerator<string> {
|
||||
if (signal?.aborted) return;
|
||||
yield JSON.stringify({
|
||||
kind: "meta",
|
||||
data: {
|
||||
v: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
source,
|
||||
filter: filter ?? {},
|
||||
},
|
||||
}) + "\n";
|
||||
try {
|
||||
for (const kind of EXPORT_KIND_ORDER) {
|
||||
if (signal?.aborted) return;
|
||||
const load = loaders[kind];
|
||||
if (!load) continue;
|
||||
if (!kindAllowed(filter, kind)) continue;
|
||||
for (const row of await load()) {
|
||||
if (signal?.aborted) return;
|
||||
if (recordAllowed(filter, kind, row)) {
|
||||
yield JSON.stringify({ kind, data: row }) + "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
yield JSON.stringify({
|
||||
kind: "error",
|
||||
data: {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
code: e instanceof Error ? e.name : undefined,
|
||||
},
|
||||
}) + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return Readable.from(generate());
|
||||
}
|
||||
|
||||
async import(
|
||||
source: NodeJS.ReadableStream,
|
||||
options: ImportOptions = {},
|
||||
): Promise<ImportResult> {
|
||||
this.#checkReadonly();
|
||||
const { filter, signal } = options;
|
||||
const counts: Partial<Record<ExportKind, number>> = {};
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: source,
|
||||
crlfDelay: Infinity,
|
||||
signal,
|
||||
});
|
||||
|
||||
// Postgres holds FK enforcement (unlike sqlite, which we toggle off): the
|
||||
// FK-safe insertion order keeps a full dump valid.
|
||||
// The transaction commits only if the whole stream is consumed cleanly; an
|
||||
// error/abort rolls it back via `multi`.
|
||||
try {
|
||||
await this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
for await (const raw of rl) {
|
||||
signal?.throwIfAborted();
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
let parsed: { kind?: unknown; data?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
throw new InvalidAbodeError();
|
||||
}
|
||||
if (parsed.kind === "meta") continue;
|
||||
if (parsed.kind === "error") {
|
||||
throw new Error(
|
||||
`export stream reported an error: ${
|
||||
(parsed.data as { message?: string })?.message ?? "unknown"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (!isExportKind(parsed.kind)) continue;
|
||||
if (!kindAllowed(filter, parsed.kind)) continue;
|
||||
const data = parsed.data as { uid?: string; aid?: string };
|
||||
if (!recordAllowed(filter, parsed.kind, data)) continue;
|
||||
await this.#importRecord(tx, parsed.kind, parsed.data);
|
||||
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
|
||||
}
|
||||
// An abort while blocked on the source closes readline without
|
||||
// throwing, so re-check before the transaction commits.
|
||||
signal?.throwIfAborted();
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
|
||||
return { counts };
|
||||
}
|
||||
|
||||
async #importRecord(
|
||||
tx: WrappedPgClient,
|
||||
kind: ExportKind,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
switch (kind) {
|
||||
case "user": {
|
||||
const u = data as ClientUser;
|
||||
// `password` is never exported; imported users land on the schema
|
||||
// default ('#unset') and must reset before they can log in.
|
||||
await tx.run(sql`
|
||||
INSERT INTO "users"("uid", "email", "name", "flags", "created_at", "updated_at")
|
||||
VALUES(
|
||||
${{ uuid: u.uid }},
|
||||
${{ text: u.email }},
|
||||
${{ text: u.name }},
|
||||
${{ jsonb: u.flags }},
|
||||
${{ date: u.created_at }},
|
||||
${{ date: u.updated_at }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "abode": {
|
||||
const a = data as Abode;
|
||||
await tx.run(sql`
|
||||
INSERT INTO "abodes"("aid", "name", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: a.aid }},
|
||||
${{ text: a.name }},
|
||||
${{ date: a.created_at }},
|
||||
${a.created_by ? { uuid: a.created_by } : { null: true }},
|
||||
${{ date: a.updated_at }},
|
||||
${a.updated_by ? { uuid: a.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "resident": {
|
||||
const r = data as Resident;
|
||||
await tx.run(sql`
|
||||
INSERT INTO "residents"("uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: r.uid }},
|
||||
${{ uuid: r.aid }},
|
||||
${{ jsonb: r.flags }},
|
||||
${{ date: r.created_at }},
|
||||
${r.created_by ? { uuid: r.created_by } : { null: true }},
|
||||
${{ date: r.updated_at }},
|
||||
${r.updated_by ? { uuid: r.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "apikey": {
|
||||
const k = data as ClientApikey;
|
||||
// `token` is never exported; mint a fresh unique one so the record's
|
||||
// metadata (kid/permissions/expiry) survives even though the original
|
||||
// secret cannot.
|
||||
await tx.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "created_at", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: k.uid }},
|
||||
${{ uuid: k.kid }},
|
||||
${{ text: createApikeyToken() }},
|
||||
${{ text: k.name }},
|
||||
${{ jsonb: k.permissions }},
|
||||
${{ date: k.created_at }},
|
||||
${k.expires_at ? { date: k.expires_at } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "note": {
|
||||
const n = data as Note;
|
||||
await tx.run(sql`
|
||||
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: n.nid }}, ${{ uuid: n.aid }}, ${{ text: n.name }},
|
||||
${{ text: n.content ?? "" }}, ${{ jsonb: n.properties }},
|
||||
${{ date: n.created_at }},
|
||||
${n.created_by ? { uuid: n.created_by } : { null: true }},
|
||||
${{ date: n.updated_at }},
|
||||
${n.updated_by ? { uuid: n.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
AppliedMigration,
|
||||
AvailableMigration,
|
||||
Migrator,
|
||||
} from "../types/Migrator.js";
|
||||
import { init, migrations } from "./migrations/index.js";
|
||||
import { pgToDate } from "./cast.js";
|
||||
import { rollbackQuietly, WrappedPgTx, WrappedPool } from "./pool.js";
|
||||
import { sql, toPositional } from "./sql.js";
|
||||
|
||||
export class PostgresMigrator implements Migrator {
|
||||
#pool: WrappedPool;
|
||||
|
||||
constructor(pool: WrappedPool) {
|
||||
this.#pool = pool;
|
||||
}
|
||||
|
||||
async #listAppliedMigrations(): Promise<
|
||||
{ id: number; name: string; applied_at: string }[] | null
|
||||
> {
|
||||
const client = await this.#pool._pool.connect();
|
||||
try {
|
||||
const existsResult = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = '_migrations'
|
||||
) AS "exists"`,
|
||||
);
|
||||
if (!existsResult.rows[0]?.exists) return null;
|
||||
|
||||
const result = await client.query<{
|
||||
id: number;
|
||||
name: string;
|
||||
applied_at: Date;
|
||||
}>(
|
||||
`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`,
|
||||
);
|
||||
return result.rows.map((x) => ({
|
||||
...x,
|
||||
applied_at: pgToDate(x.applied_at),
|
||||
}));
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async listAppliedMigrations(): Promise<AppliedMigration[]> {
|
||||
return (await this.#listAppliedMigrations()) ?? [];
|
||||
}
|
||||
|
||||
listAvailableMigrations(): AvailableMigration[] {
|
||||
return migrations.map((m) => ({ id: m.id, name: m.name }));
|
||||
}
|
||||
|
||||
async migrateTo(id: number): Promise<void> {
|
||||
const target = migrations.find((x) => x.id === id);
|
||||
if (!target) throw new Error(`No known migration with id ${id}`);
|
||||
|
||||
let current = await this.#listAppliedMigrations();
|
||||
if (!current) {
|
||||
const client = await this.#pool._pool.connect();
|
||||
try {
|
||||
await client.query(init);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
current = [];
|
||||
}
|
||||
|
||||
for (const { id, name } of current) {
|
||||
const migration = migrations.find((x) => x.id === id);
|
||||
if (!migration)
|
||||
throw new Error(`Applied migration ${id} (${name}) not known`);
|
||||
if (migration.name !== name)
|
||||
throw new Error(
|
||||
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
const start = migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
|
||||
const end = migrations.indexOf(target) + 1;
|
||||
|
||||
if (end < start) {
|
||||
throw new Error(
|
||||
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${target.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const toApply = migrations.slice(start, end);
|
||||
|
||||
if (!toApply.length) {
|
||||
console.log("Nothing to do");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const migration of toApply) {
|
||||
console.log(`Applying migration ${migration.id} (${migration.name})`);
|
||||
const client = await this.#pool._pool.connect();
|
||||
const tx = new WrappedPgTx(client, false);
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
for (const part of migration.parts) {
|
||||
console.log(`- Applying part ${part.id} (${part.name})`);
|
||||
if ("sql" in part) {
|
||||
await client.query(part.sql);
|
||||
} else {
|
||||
await part.apply(tx);
|
||||
}
|
||||
}
|
||||
const recordSql = sql`
|
||||
INSERT INTO "_migrations"("id", "name")
|
||||
VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
|
||||
`;
|
||||
await client.query(toPositional(recordSql._sql), recordSql._vars);
|
||||
await client.query("COMMIT");
|
||||
} catch (e) {
|
||||
await rollbackQuietly(client);
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done migrating database");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Abode } from "../types/Abode.js";
|
||||
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
|
||||
import type { Resident, ResidentFlags } from "../types/Resident.js";
|
||||
import type {
|
||||
Note,
|
||||
NoteProperties,
|
||||
PartialNote,
|
||||
PartialNoteProperties,
|
||||
} from "../types/Note.js";
|
||||
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
|
||||
|
||||
export function pgToDate(d: Date | string): string {
|
||||
return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
|
||||
}
|
||||
|
||||
const defaultUserFlags: UserFlags = {};
|
||||
export function pgToUserFlags(flags: unknown): UserFlags {
|
||||
const out = { ...defaultUserFlags };
|
||||
if (typeof flags !== "object" || !flags || Array.isArray(flags)) return out;
|
||||
const f = flags as Record<string, unknown>;
|
||||
if (f.admin === true) out.admin = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pgToPartialUser(user: {
|
||||
uid: string;
|
||||
name: string;
|
||||
flags: unknown;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
}): PartialUser {
|
||||
return {
|
||||
uid: user.uid,
|
||||
name: user.name,
|
||||
flags: pgToUserFlags(user.flags),
|
||||
created_at: pgToDate(user.created_at),
|
||||
updated_at: pgToDate(user.updated_at),
|
||||
};
|
||||
}
|
||||
export function pgToClientUser(user: {
|
||||
uid: string;
|
||||
email: string;
|
||||
name: string;
|
||||
flags: unknown;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
}): ClientUser {
|
||||
return {
|
||||
...pgToPartialUser(user),
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
export function pgToAbode(abode: {
|
||||
aid: string;
|
||||
name: string;
|
||||
created_at: Date | string;
|
||||
created_by: string | null;
|
||||
updated_at: Date | string;
|
||||
updated_by: string | null;
|
||||
}): Abode {
|
||||
return {
|
||||
aid: abode.aid,
|
||||
name: abode.name,
|
||||
created_at: pgToDate(abode.created_at),
|
||||
created_by: abode.created_by,
|
||||
updated_at: pgToDate(abode.updated_at),
|
||||
updated_by: abode.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultResidentFlags: ResidentFlags = {};
|
||||
export function pgToResidentFlags(flags: unknown): ResidentFlags {
|
||||
const out = { ...defaultResidentFlags };
|
||||
if (typeof flags !== "object" || !flags || Array.isArray(flags)) return out;
|
||||
const f = flags as Record<string, unknown>;
|
||||
if (f.admin === true) out.admin = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pgToResident(resident: {
|
||||
uid: string;
|
||||
aid: string;
|
||||
flags: unknown;
|
||||
created_at: Date | string;
|
||||
created_by: string | null;
|
||||
updated_at: Date | string;
|
||||
updated_by: string | null;
|
||||
}): Resident {
|
||||
return {
|
||||
uid: resident.uid,
|
||||
aid: resident.aid,
|
||||
flags: pgToResidentFlags(resident.flags),
|
||||
created_at: pgToDate(resident.created_at),
|
||||
created_by: resident.created_by,
|
||||
updated_at: pgToDate(resident.updated_at),
|
||||
updated_by: resident.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultApikeyPermissions: ApikeyPermissions = {};
|
||||
export function pgToApikeyPermissions(permissions: unknown): ApikeyPermissions {
|
||||
const out = { ...defaultApikeyPermissions };
|
||||
if (
|
||||
typeof permissions !== "object" ||
|
||||
!permissions ||
|
||||
Array.isArray(permissions)
|
||||
)
|
||||
return out;
|
||||
const p = permissions as Record<string, unknown>;
|
||||
if (p.admin === true) out.admin = true;
|
||||
if (p.all === true) out.all = true;
|
||||
for (const key of ["users", "residents", "abodes"] as const) {
|
||||
if (p[key] === "r" || p[key] === "rw") out[key] = p[key] as "r" | "rw";
|
||||
}
|
||||
for (const key of ["restrict_users", "restrict_abodes"] as const) {
|
||||
if (
|
||||
Array.isArray(p[key]) &&
|
||||
(p[key] as unknown[]).every((x) => typeof x === "string")
|
||||
) {
|
||||
out[key] = p[key] as string[];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pgToClientApikey(apikey: {
|
||||
uid: string;
|
||||
kid: string;
|
||||
name: string;
|
||||
permissions: unknown;
|
||||
created_at: Date | string;
|
||||
expires_at: Date | string | null;
|
||||
}): ClientApikey {
|
||||
return {
|
||||
uid: apikey.uid,
|
||||
kid: apikey.kid,
|
||||
name: apikey.name,
|
||||
permissions: pgToApikeyPermissions(apikey.permissions),
|
||||
created_at: pgToDate(apikey.created_at),
|
||||
expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const validNoteTypes = new Set(["note"]);
|
||||
|
||||
function pgToNoteProperties(properties: unknown): NoteProperties {
|
||||
if (
|
||||
typeof properties !== "object" ||
|
||||
!properties ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
return {};
|
||||
const value = properties as Record<string, unknown>;
|
||||
return validNoteTypes.has(value.type as string)
|
||||
? { type: value.type as "note" }
|
||||
: {};
|
||||
}
|
||||
|
||||
function pgToPartialNoteProperties(properties: unknown): PartialNoteProperties {
|
||||
return { type: pgToNoteProperties(properties).type ?? "note" };
|
||||
}
|
||||
|
||||
export function pgToNote(note: {
|
||||
nid: string;
|
||||
aid: string;
|
||||
name: string;
|
||||
content: string;
|
||||
properties: unknown;
|
||||
created_at: Date | string;
|
||||
created_by: string | null;
|
||||
updated_at: Date | string;
|
||||
updated_by: string | null;
|
||||
}): Note {
|
||||
return {
|
||||
nid: note.nid,
|
||||
aid: note.aid,
|
||||
name: note.name,
|
||||
content: note.content,
|
||||
properties: pgToNoteProperties(note.properties),
|
||||
created_at: pgToDate(note.created_at),
|
||||
created_by: note.created_by,
|
||||
updated_at: pgToDate(note.updated_at),
|
||||
updated_by: note.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
export function pgToPartialNote(note: {
|
||||
nid: string;
|
||||
aid: string;
|
||||
name: string;
|
||||
properties: unknown;
|
||||
created_at: Date | string;
|
||||
created_by: string | null;
|
||||
updated_at: Date | string;
|
||||
updated_by: string | null;
|
||||
}): PartialNote {
|
||||
return {
|
||||
nid: note.nid,
|
||||
aid: note.aid,
|
||||
name: note.name,
|
||||
properties: pgToPartialNoteProperties(note.properties),
|
||||
created_at: pgToDate(note.created_at),
|
||||
created_by: note.created_by,
|
||||
updated_at: pgToDate(note.updated_at),
|
||||
updated_by: note.updated_by,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GetDbStatic } from "../types/GetDb.js";
|
||||
import { WrappedPool } from "./pool.js";
|
||||
import { PostgresInterface } from "./PostgresInterface.js";
|
||||
import { PostgresMigrator } from "./PostgresMigrator.js";
|
||||
import { isPgUrl, parsePgUrl, pgProtocols } from "./url.js";
|
||||
|
||||
const getPgStatic: GetDbStatic = {
|
||||
name: "postgres",
|
||||
protocols: pgProtocols,
|
||||
checkUrl: isPgUrl,
|
||||
getDbInterface: async (url) => {
|
||||
const { connectionString, readonly } = parsePgUrl(url);
|
||||
return new PostgresInterface(new WrappedPool(connectionString, readonly));
|
||||
},
|
||||
getMigrator: async (url) => {
|
||||
const { connectionString } = parsePgUrl(url);
|
||||
return new PostgresMigrator(new WrappedPool(connectionString));
|
||||
},
|
||||
};
|
||||
export default getPgStatic;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "users" (
|
||||
"uid" UUID NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL UNIQUE,
|
||||
"name" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL DEFAULT '#unset',
|
||||
"flags" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "abodes" (
|
||||
"aid" UUID NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "residents" (
|
||||
"uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||
"aid" UUID NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE,
|
||||
"flags" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||
|
||||
PRIMARY KEY("uid", "aid")
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PgMigration } from "../types.js";
|
||||
import p1 from "./1.users.pg.sql";
|
||||
import p2 from "./2.abodes.pg.sql";
|
||||
import p3 from "./3.residents.pg.sql";
|
||||
|
||||
export const m1: PgMigration = {
|
||||
id: 1,
|
||||
name: "init",
|
||||
parts: [
|
||||
{ id: 1, name: "users", sql: p1 },
|
||||
{ id: 2, name: "abodes", sql: p2 },
|
||||
{ id: 3, name: "residents", sql: p3 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "sessions" (
|
||||
"uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"expires_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "apikeys" (
|
||||
"uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||
"kid" UUID NOT NULL PRIMARY KEY,
|
||||
"token" TEXT NOT NULL UNIQUE,
|
||||
"name" TEXT NOT NULL,
|
||||
"permissions" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"expires_at" TIMESTAMPTZ
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PgMigration } from "../types.js";
|
||||
import p1 from "./1.sessions.pg.sql";
|
||||
import p2 from "./2.apikeys.pg.sql";
|
||||
|
||||
export const m2: PgMigration = {
|
||||
id: 2,
|
||||
name: "auth",
|
||||
parts: [
|
||||
{ id: 1, name: "sessions", sql: p1 },
|
||||
{ id: 2, name: "apikeys", sql: p2 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "notes" (
|
||||
"nid" UUID NOT NULL PRIMARY KEY,
|
||||
"aid" UUID NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE,
|
||||
"name" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL DEFAULT '',
|
||||
"properties" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PgMigration } from "../types.js";
|
||||
import p1 from "./1.notes.pg.sql";
|
||||
|
||||
export const m3: PgMigration = {
|
||||
id: 3,
|
||||
name: "notes",
|
||||
parts: [{ id: 1, name: "notes", sql: p1 }],
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { m1 } from "./1.init/index.js";
|
||||
import { m2 } from "./2.auth/index.js";
|
||||
import { m3 } from "./3.notes/index.js";
|
||||
import type { PgMigration } from "./types.js";
|
||||
|
||||
export { default as init } from "./init.pg.sql";
|
||||
export const migrations: PgMigration[] = [m1, m2, m3];
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE "_migrations" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WrappedPgClient } from "../pool.js";
|
||||
|
||||
export type PgMigrationPart = {
|
||||
id: number;
|
||||
name: string;
|
||||
} & (
|
||||
| {
|
||||
sql: string;
|
||||
}
|
||||
| {
|
||||
apply: (client: WrappedPgClient) => Promise<void>;
|
||||
}
|
||||
);
|
||||
|
||||
export type PgMigration = {
|
||||
id: number;
|
||||
name: string;
|
||||
parts: PgMigrationPart[];
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import pg from "pg";
|
||||
import { ConflictAbodeError, NotFoundAbodeError } from "../types/errors.js";
|
||||
import type { SqlCode } from "./sql.js";
|
||||
import { toPositional } from "./sql.js";
|
||||
|
||||
export interface WrappedPgClient {
|
||||
readonly: boolean;
|
||||
destroy(): Promise<void>;
|
||||
all<R>(stmt: SqlCode): Promise<R[]>;
|
||||
get<R>(stmt: SqlCode): Promise<R | null>;
|
||||
run(stmt: SqlCode): Promise<{ changes: number }>;
|
||||
multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R>;
|
||||
rethrow<R>(fn: () => Promise<R>): Promise<R>;
|
||||
}
|
||||
|
||||
async function rethrow<R>(fn: () => Promise<R>): Promise<R> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && "code" in e) {
|
||||
switch ((e as NodeJS.ErrnoException).code) {
|
||||
case "23505":
|
||||
throw new ConflictAbodeError();
|
||||
case "23503":
|
||||
throw new NotFoundAbodeError();
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback on a broken connection can itself throw; the original error is
|
||||
// the one worth surfacing.
|
||||
export async function rollbackQuietly(client: pg.PoolClient): Promise<void> {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
abstract class WrappedPgBase implements WrappedPgClient {
|
||||
#queryable: pg.Pool | pg.PoolClient;
|
||||
#readonly: boolean;
|
||||
|
||||
constructor(queryable: pg.Pool | pg.PoolClient, readonly_: boolean) {
|
||||
this.#queryable = queryable;
|
||||
this.#readonly = readonly_;
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this.#readonly;
|
||||
}
|
||||
|
||||
abstract destroy(): Promise<void>;
|
||||
abstract multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R>;
|
||||
|
||||
async all<R>(stmt: SqlCode): Promise<R[]> {
|
||||
const result = await this.#queryable.query(
|
||||
toPositional(stmt._sql),
|
||||
stmt._vars,
|
||||
);
|
||||
return result.rows as R[];
|
||||
}
|
||||
|
||||
async get<R>(stmt: SqlCode): Promise<R | null> {
|
||||
const rows = await this.all<R>(stmt);
|
||||
if (rows.length > 1) throw new Error("Multiple results");
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async run(stmt: SqlCode): Promise<{ changes: number }> {
|
||||
const result = await this.#queryable.query(
|
||||
toPositional(stmt._sql),
|
||||
stmt._vars,
|
||||
);
|
||||
return { changes: result.rowCount ?? 0 };
|
||||
}
|
||||
|
||||
rethrow = rethrow;
|
||||
}
|
||||
|
||||
export class WrappedPgTx extends WrappedPgBase {
|
||||
constructor(client: pg.PoolClient, readonly_: boolean) {
|
||||
super(client, readonly_);
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {}
|
||||
|
||||
multi<R>(_fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
|
||||
throw new Error("Nested transactions not supported");
|
||||
}
|
||||
}
|
||||
|
||||
export class WrappedPool extends WrappedPgBase {
|
||||
#pool: pg.Pool;
|
||||
|
||||
constructor(connectionStringOrPool: string | pg.Pool, readonly_ = false) {
|
||||
const pool =
|
||||
typeof connectionStringOrPool === "string"
|
||||
? new pg.Pool({ connectionString: connectionStringOrPool })
|
||||
: connectionStringOrPool;
|
||||
super(pool, readonly_);
|
||||
this.#pool = pool;
|
||||
}
|
||||
|
||||
get _pool(): pg.Pool {
|
||||
return this.#pool;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
await this.#pool.end();
|
||||
}
|
||||
|
||||
async multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
|
||||
const client = await this.#pool.connect();
|
||||
const tx = new WrappedPgTx(client, this.readonly);
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(tx);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
await rollbackQuietly(client);
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Abode } from "../types/Abode.js";
|
||||
import type { ClientApikey } from "../types/Apikey.js";
|
||||
import type { Resident } from "../types/Resident.js";
|
||||
import type { ClientUser } from "../types/User.js";
|
||||
import type { Note, PartialNote } from "../types/Note.js";
|
||||
import {
|
||||
pgToAbode,
|
||||
pgToClientApikey,
|
||||
pgToClientUser,
|
||||
pgToResident,
|
||||
pgToNote,
|
||||
pgToPartialNote,
|
||||
} from "./cast.js";
|
||||
import type { WrappedPgClient } from "./pool.js";
|
||||
import { sql, type SqlCode } from "./sql.js";
|
||||
|
||||
type RawClientUser = {
|
||||
uid: string;
|
||||
email: string;
|
||||
name: string;
|
||||
flags: unknown;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
};
|
||||
const sqlClientUser = sql`
|
||||
SELECT u."uid", u."email", u."name", u."flags", u."created_at", u."updated_at"
|
||||
FROM "users" u
|
||||
`;
|
||||
|
||||
export async function selectClientUser(
|
||||
db: WrappedPgClient,
|
||||
where: SqlCode,
|
||||
): Promise<ClientUser | null> {
|
||||
const raw = await db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
|
||||
if (raw) return pgToClientUser(raw);
|
||||
return null;
|
||||
}
|
||||
export async function selectClientUsers(
|
||||
db: WrappedPgClient,
|
||||
rest?: SqlCode,
|
||||
): Promise<ClientUser[]> {
|
||||
const rows = await db.all<RawClientUser>(
|
||||
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
|
||||
);
|
||||
return rows.map(pgToClientUser);
|
||||
}
|
||||
|
||||
type RawAbode = {
|
||||
aid: string;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
updated_at: Date;
|
||||
updated_by: string | null;
|
||||
};
|
||||
const sqlAbode = sql`
|
||||
SELECT a."aid", a."name", a."created_at", a."created_by", a."updated_at", a."updated_by"
|
||||
FROM "abodes" a
|
||||
`;
|
||||
|
||||
export async function selectAbode(
|
||||
db: WrappedPgClient,
|
||||
where: SqlCode,
|
||||
): Promise<Abode | null> {
|
||||
const raw = await db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`);
|
||||
if (raw) return pgToAbode(raw);
|
||||
return null;
|
||||
}
|
||||
export async function selectAbodes(
|
||||
db: WrappedPgClient,
|
||||
rest?: SqlCode,
|
||||
): Promise<Abode[]> {
|
||||
const rows = await db.all<RawAbode>(
|
||||
rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
|
||||
);
|
||||
return rows.map(pgToAbode);
|
||||
}
|
||||
|
||||
type RawResident = {
|
||||
uid: string;
|
||||
aid: string;
|
||||
flags: unknown;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
updated_at: Date;
|
||||
updated_by: string | null;
|
||||
};
|
||||
const sqlResident = sql`
|
||||
SELECT "uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by"
|
||||
FROM "residents"
|
||||
`;
|
||||
|
||||
export async function selectResident(
|
||||
db: WrappedPgClient,
|
||||
where: SqlCode,
|
||||
): Promise<Resident | null> {
|
||||
const raw = await db.get<RawResident>(sql`${sqlResident} WHERE ${where}`);
|
||||
if (raw) return pgToResident(raw);
|
||||
return null;
|
||||
}
|
||||
export async function selectResidents(
|
||||
db: WrappedPgClient,
|
||||
where?: SqlCode,
|
||||
): Promise<Resident[]> {
|
||||
const rows = await db.all<RawResident>(
|
||||
where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
|
||||
);
|
||||
return rows.map(pgToResident);
|
||||
}
|
||||
|
||||
type RawClientApikey = {
|
||||
uid: string;
|
||||
kid: string;
|
||||
name: string;
|
||||
permissions: unknown;
|
||||
created_at: Date;
|
||||
expires_at: Date | null;
|
||||
};
|
||||
const sqlClientApikey = sql`
|
||||
SELECT k."uid", k."kid", k."name", k."permissions", k."created_at", k."expires_at"
|
||||
FROM "apikeys" k
|
||||
`;
|
||||
|
||||
export async function selectClientApikey(
|
||||
db: WrappedPgClient,
|
||||
where: SqlCode,
|
||||
): Promise<ClientApikey | null> {
|
||||
const raw = await db.get<RawClientApikey>(
|
||||
sql`${sqlClientApikey} WHERE ${where}`,
|
||||
);
|
||||
if (raw) return pgToClientApikey(raw);
|
||||
return null;
|
||||
}
|
||||
export async function selectClientApikeys(
|
||||
db: WrappedPgClient,
|
||||
where?: SqlCode,
|
||||
): Promise<ClientApikey[]> {
|
||||
const rows = await db.all<RawClientApikey>(
|
||||
where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey,
|
||||
);
|
||||
return rows.map(pgToClientApikey);
|
||||
}
|
||||
|
||||
type RawNote = {
|
||||
nid: string;
|
||||
aid: string;
|
||||
name: string;
|
||||
content: string;
|
||||
properties: unknown;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
updated_at: Date;
|
||||
updated_by: string | null;
|
||||
};
|
||||
const sqlNote = sql`
|
||||
SELECT n."nid", n."aid", n."name", n."content", n."properties",
|
||||
n."created_at", n."created_by", n."updated_at", n."updated_by"
|
||||
FROM "notes" n
|
||||
`;
|
||||
|
||||
type RawPartialNote = Omit<RawNote, "content">;
|
||||
const sqlPartialNote = sql`
|
||||
SELECT n."nid", n."aid", n."name", n."properties",
|
||||
n."created_at", n."created_by", n."updated_at", n."updated_by"
|
||||
FROM "notes" n
|
||||
`;
|
||||
|
||||
export async function selectNote(
|
||||
db: WrappedPgClient,
|
||||
where: SqlCode,
|
||||
): Promise<Note | null> {
|
||||
const raw = await db.get<RawNote>(sql`${sqlNote} WHERE ${where}`);
|
||||
return raw ? pgToNote(raw) : null;
|
||||
}
|
||||
export async function selectNotes(
|
||||
db: WrappedPgClient,
|
||||
where?: SqlCode,
|
||||
): Promise<Note[]> {
|
||||
const rows = await db.all<RawNote>(
|
||||
where ? sql`${sqlNote} WHERE ${where}` : sqlNote,
|
||||
);
|
||||
return rows.map(pgToNote);
|
||||
}
|
||||
export async function selectPartialNotes(
|
||||
db: WrappedPgClient,
|
||||
where?: SqlCode,
|
||||
): Promise<PartialNote[]> {
|
||||
const rows = await db.all<RawPartialNote>(
|
||||
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote,
|
||||
);
|
||||
return rows.map(pgToPartialNote);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export type SqlVar = string | number;
|
||||
export type SqlCode = { _sql: string; _vars: SqlVar[] };
|
||||
type SqlArg =
|
||||
| { uuid: string }
|
||||
| { text: string }
|
||||
| { jsonb: unknown }
|
||||
| { date: string }
|
||||
| { int: number }
|
||||
| { null: true }
|
||||
| SqlCode;
|
||||
|
||||
export function sql(text: TemplateStringsArray, ...args: SqlArg[]): SqlCode {
|
||||
let code = "";
|
||||
const vars: SqlVar[] = [];
|
||||
for (const [i, part] of text.entries()) {
|
||||
code += part;
|
||||
if (i < args.length) {
|
||||
const arg = args[i];
|
||||
if ("uuid" in arg) {
|
||||
code += "?";
|
||||
vars.push(arg.uuid);
|
||||
} else if ("text" in arg) {
|
||||
code += "?";
|
||||
vars.push(arg.text);
|
||||
} else if ("jsonb" in arg) {
|
||||
code += "?::jsonb";
|
||||
vars.push(JSON.stringify(arg.jsonb));
|
||||
} else if ("date" in arg) {
|
||||
code += "?::timestamptz";
|
||||
vars.push(arg.date);
|
||||
} else if ("int" in arg) {
|
||||
if (arg.int % 1) throw new Error("Not an integer");
|
||||
code += "?";
|
||||
vars.push(arg.int);
|
||||
} else if ("null" in arg) {
|
||||
code += "NULL";
|
||||
} else {
|
||||
code += arg._sql;
|
||||
for (const v of arg._vars) vars.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { _sql: code, _vars: vars };
|
||||
}
|
||||
|
||||
export function catSql(a: SqlCode, b: SqlCode): SqlCode {
|
||||
return {
|
||||
_sql: a._sql + b._sql,
|
||||
_vars: [...a._vars, ...b._vars],
|
||||
};
|
||||
}
|
||||
export function joinSql(parts: SqlCode[], joiner: SqlCode): SqlCode {
|
||||
return parts.reduce((a, b) => catSql(catSql(a, joiner), b));
|
||||
}
|
||||
|
||||
export function unsafeSql(s: string): SqlCode {
|
||||
return { _sql: s, _vars: [] };
|
||||
}
|
||||
|
||||
export function calcUpdates<T extends object>(updater: {
|
||||
[K in keyof T]: (value: NonNullable<T[K]>) => SqlCode;
|
||||
}): (obj: Partial<T>) => SqlCode[] {
|
||||
return (obj) => {
|
||||
const updates: SqlCode[] = [];
|
||||
for (const [prop, update] of Object.entries(updater)) {
|
||||
if (prop in obj) {
|
||||
updates.push(
|
||||
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!),
|
||||
);
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
};
|
||||
}
|
||||
|
||||
// Rewrites every literal `?` into a numbered placeholder, so queries must
|
||||
// not contain Postgres's JSONB `?` / `?|` / `?&` operators (use
|
||||
// `jsonb_exists`, `jsonb_exists_any`, `jsonb_exists_all` instead).
|
||||
export function toPositional(sql: string): string {
|
||||
let i = 0;
|
||||
return sql.replace(/\?/g, () => `$${++i}`);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const pgProtocols = ["postgres:", "postgresql:"];
|
||||
|
||||
export function isPgUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return pgProtocols.includes(urlObj.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePgUrl(url: string): {
|
||||
connectionString: string;
|
||||
readonly: boolean;
|
||||
} {
|
||||
if (!isPgUrl(url)) throw new Error("Not a postgres: URL");
|
||||
const urlObj = new URL(url);
|
||||
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
|
||||
urlObj.searchParams.delete("readonly");
|
||||
return { connectionString: urlObj.toString(), readonly };
|
||||
}
|
||||
@@ -33,6 +33,9 @@ import {
|
||||
selectClientApikeys,
|
||||
selectClientUser,
|
||||
selectClientUsers,
|
||||
selectNote,
|
||||
selectNotes,
|
||||
selectPartialNotes,
|
||||
selectResident,
|
||||
selectResidents,
|
||||
} from "./query.js";
|
||||
@@ -43,8 +46,22 @@ import type {
|
||||
UpdateNote,
|
||||
} from "../types/Note.js";
|
||||
import type { WrappedDb } from "./impl/types.js";
|
||||
import { Readable } from "node:stream";
|
||||
import readline from "node:readline";
|
||||
import {
|
||||
EXPORT_KIND_ORDER,
|
||||
type Exportable,
|
||||
type ExportKind,
|
||||
type ExportOptions,
|
||||
type Importable,
|
||||
type ImportOptions,
|
||||
type ImportResult,
|
||||
} from "../types/ExportImport.js";
|
||||
import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js";
|
||||
|
||||
export class SqliteInterface implements BackendDbInterface {
|
||||
export class SqliteInterface
|
||||
implements BackendDbInterface, Exportable, Importable
|
||||
{
|
||||
#db: WrappedDb;
|
||||
|
||||
constructor(db: WrappedDb) {
|
||||
@@ -108,7 +125,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
|
||||
FROM "users"
|
||||
WHERE "email" = ${{ text: email }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
if (!rawUser) throw new NotFoundAbodeError();
|
||||
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
|
||||
@@ -122,7 +139,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
sql`
|
||||
DELETE FROM "users"
|
||||
WHERE "uid" = ${{ uuid: id }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
@@ -140,10 +157,10 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
${{ text: user.email }},
|
||||
${{ text: user.name }},
|
||||
${{ text: user.password }},${{ jsonb: user.flags }})
|
||||
`
|
||||
`,
|
||||
);
|
||||
return this.#getUserById(uid);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||
@@ -177,11 +194,11 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getUserById(user.uid);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,7 +219,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
sql`
|
||||
DELETE FROM "abodes"
|
||||
WHERE "aid" = ${{ uuid: id }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
@@ -220,10 +237,10 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`
|
||||
`,
|
||||
);
|
||||
return this.#getAbodeById(aid);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||
@@ -242,11 +259,11 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
"updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "aid" = ${{ uuid: abode.aid }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getAbodeById(abode.aid);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,7 +279,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
#getResidentById(uid: string, aid: string): Resident {
|
||||
const resident = selectResident(
|
||||
this.#db,
|
||||
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`
|
||||
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
|
||||
);
|
||||
if (!resident) throw new NotFoundAbodeError();
|
||||
return resident;
|
||||
@@ -276,13 +293,13 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
sql`
|
||||
DELETE FROM "residents"
|
||||
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createResident(
|
||||
resident: CreateResident,
|
||||
ctx: { uid: string }
|
||||
ctx: { uid: string },
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
return this.#db.rethrow(() =>
|
||||
@@ -297,15 +314,15 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`
|
||||
`,
|
||||
);
|
||||
return this.#getResidentById(resident.uid, resident.aid);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateResident(
|
||||
resident: updateResident,
|
||||
ctx: { uid: string }
|
||||
ctx: { uid: string },
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
@@ -319,16 +336,16 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
UPDATE "residents"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
"updated_by" = ${{ uuid: ctx.uid }}
|
||||
"updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE
|
||||
"uid" = ${{ uuid: resident.uid }}
|
||||
AND "aid" = ${{ uuid: resident.aid }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getResidentById(resident.uid, resident.aid);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -338,7 +355,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
sql`
|
||||
JOIN "residents" r ON u."uid" = r."uid"
|
||||
WHERE r."aid" = ${{ uuid: id }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
}
|
||||
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
||||
@@ -347,7 +364,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
sql`
|
||||
JOIN "residents" r ON a."aid" = r."aid"
|
||||
WHERE r."uid" = ${{ uuid: id }}
|
||||
`
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,17 +409,24 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
WHERE "uid" = ${{ uuid: uid }}
|
||||
`);
|
||||
}
|
||||
async deleteSession(token: `as_${string}`): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
}
|
||||
|
||||
#getApikeyByToken(token: `at_${string}`): ClientApikey {
|
||||
const apikey = selectClientApikey(
|
||||
this.#db,
|
||||
sql`"token" = ${{ text: token }}`
|
||||
sql`"token" = ${{ text: token }}`,
|
||||
);
|
||||
if (!apikey) throw new NotFoundAbodeError();
|
||||
return apikey;
|
||||
}
|
||||
async getUserByApikey(
|
||||
token: `at_${string}`
|
||||
token: `at_${string}`,
|
||||
): Promise<[ClientUser, ClientApikey]> {
|
||||
const apikey = this.#getApikeyByToken(token);
|
||||
if (
|
||||
@@ -422,7 +446,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
return apikey;
|
||||
}
|
||||
async createApikey(
|
||||
apikey: CreateApikey
|
||||
apikey: CreateApikey,
|
||||
): Promise<[ClientApikey, `at_${string}`]> {
|
||||
this.#checkReadonly();
|
||||
const token = createApikeyToken();
|
||||
@@ -430,7 +454,7 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
let expires = apikey.expires_at;
|
||||
if (expires === undefined)
|
||||
expires = new Date(
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
|
||||
).toISOString();
|
||||
if (expires && new Date(expires).getTime() < Date.now())
|
||||
throw new InvalidAbodeError();
|
||||
@@ -458,24 +482,288 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return selectPartialNotes(this.#db);
|
||||
}
|
||||
#getNoteById(nid: string): Note {
|
||||
const note = selectNote(this.#db, sql`n."nid" = ${{ uuid: nid }}`);
|
||||
if (!note) throw new NotFoundAbodeError();
|
||||
return note;
|
||||
}
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#getNoteById(nid);
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
throw new Error("Unimplemented");
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(
|
||||
sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}`,
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
this.#checkReadonly();
|
||||
const nid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
this.#db.run(sql`
|
||||
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: nid }},
|
||||
${{ uuid: note.aid }},
|
||||
${{ text: note.name }},
|
||||
${{ text: note.content ?? "" }},
|
||||
${{ jsonb: note.properties }},
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`);
|
||||
return this.#getNoteById(nid);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
content: (value: string) => sql`"content" = ${{ text: value }}`,
|
||||
properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`,
|
||||
})(note);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
const { changes } = this.#db.run(sql`
|
||||
UPDATE "notes"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
"updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "nid" = ${{ uuid: note.nid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getNoteById(note.nid);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
|
||||
}
|
||||
|
||||
export(options: ExportOptions = {}): NodeJS.ReadableStream {
|
||||
const { filter, signal } = options;
|
||||
const db = this.#db;
|
||||
const source = this.name;
|
||||
|
||||
// Per-table full materialization + JS-side per-record yielding (each
|
||||
// `load()` is exactly one `WrappedDb.all()`). Kept lazy so the first query
|
||||
// only fires once the destination starts pulling, and skipped entirely
|
||||
// once the signal is aborted — no further reads after the destination
|
||||
// goes away. Emission follows the FK-safe EXPORT_KIND_ORDER (part of the
|
||||
// wire contract; see ExportImport.ts).
|
||||
const loaders: Record<ExportKind, () => { uid?: string; aid?: string }[]> =
|
||||
{
|
||||
user: () => selectClientUsers(db),
|
||||
abode: () => selectAbodes(db),
|
||||
resident: () => selectResidents(db),
|
||||
apikey: () => selectClientApikeys(db),
|
||||
note: () => selectNotes(db),
|
||||
};
|
||||
|
||||
async function* generate(): AsyncGenerator<string> {
|
||||
if (signal?.aborted) return;
|
||||
yield JSON.stringify({
|
||||
kind: "meta",
|
||||
data: {
|
||||
v: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
source,
|
||||
filter: filter ?? {},
|
||||
},
|
||||
}) + "\n";
|
||||
try {
|
||||
for (const kind of EXPORT_KIND_ORDER) {
|
||||
if (signal?.aborted) return;
|
||||
if (!kindAllowed(filter, kind)) continue;
|
||||
for (const row of loaders[kind]()) {
|
||||
if (signal?.aborted) return;
|
||||
if (recordAllowed(filter, kind, row)) {
|
||||
yield JSON.stringify({ kind, data: row }) + "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Aborts unwind via early `return`, never here; a genuine mid-stream
|
||||
// failure is surfaced as a trailing sentinel line (HTTP 200 headers
|
||||
// are already flushed, so `convertError` can no longer apply).
|
||||
if (signal?.aborted) return;
|
||||
yield JSON.stringify({
|
||||
kind: "error",
|
||||
data: {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
code: e instanceof Error ? e.name : undefined,
|
||||
},
|
||||
}) + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return Readable.from(generate());
|
||||
}
|
||||
|
||||
async import(
|
||||
source: NodeJS.ReadableStream,
|
||||
options: ImportOptions = {},
|
||||
): Promise<ImportResult> {
|
||||
this.#checkReadonly();
|
||||
const { filter, signal } = options;
|
||||
const db = this.#db;
|
||||
const counts: Partial<Record<ExportKind, number>> = {};
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: source,
|
||||
crlfDelay: Infinity,
|
||||
signal,
|
||||
});
|
||||
|
||||
// Bulk restore trusts the export's referential integrity, and a filtered
|
||||
// dump may legitimately reference `created_by`/`updated_by` users outside
|
||||
// its scope. Suppress FK enforcement for the duration (can only be toggled
|
||||
// outside a transaction) and restore it in `finally`.
|
||||
db.run(sql`PRAGMA foreign_keys = OFF`);
|
||||
db.run(sql`BEGIN`);
|
||||
try {
|
||||
for await (const raw of rl) {
|
||||
signal?.throwIfAborted();
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
let parsed: { kind?: unknown; data?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
throw new InvalidAbodeError();
|
||||
}
|
||||
if (parsed.kind === "meta") continue;
|
||||
if (parsed.kind === "error") {
|
||||
throw new Error(
|
||||
`export stream reported an error: ${
|
||||
(parsed.data as { message?: string })?.message ?? "unknown"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (!isExportKind(parsed.kind)) continue;
|
||||
if (!kindAllowed(filter, parsed.kind)) continue;
|
||||
const data = parsed.data as { uid?: string; aid?: string };
|
||||
if (!recordAllowed(filter, parsed.kind, data)) continue;
|
||||
this.#importRecord(parsed.kind, parsed.data);
|
||||
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
|
||||
}
|
||||
// An abort while blocked on the source closes readline without throwing,
|
||||
// so re-check before committing to guarantee no partial commit.
|
||||
signal?.throwIfAborted();
|
||||
db.run(sql`COMMIT`);
|
||||
} catch (e) {
|
||||
try {
|
||||
db.run(sql`ROLLBACK`);
|
||||
} catch {
|
||||
/* already rolled back */
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
rl.close();
|
||||
db.run(sql`PRAGMA foreign_keys = ON`);
|
||||
}
|
||||
|
||||
return { counts };
|
||||
}
|
||||
|
||||
#importRecord(kind: ExportKind, data: unknown): void {
|
||||
const db = this.#db;
|
||||
switch (kind) {
|
||||
case "user": {
|
||||
const u = data as ClientUser;
|
||||
// `password` is never exported; imported users land on the schema
|
||||
// default ('#unset') and must reset before they can log in.
|
||||
db.run(sql`
|
||||
INSERT INTO "users"("uid", "email", "name", "flags", "created_at", "updated_at")
|
||||
VALUES(
|
||||
${{ uuid: u.uid }},
|
||||
${{ text: u.email }},
|
||||
${{ text: u.name }},
|
||||
${{ jsonb: u.flags }},
|
||||
${{ date: u.created_at }},
|
||||
${{ date: u.updated_at }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "abode": {
|
||||
const a = data as Abode;
|
||||
db.run(sql`
|
||||
INSERT INTO "abodes"("aid", "name", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: a.aid }},
|
||||
${{ text: a.name }},
|
||||
${{ date: a.created_at }},
|
||||
${a.created_by ? { uuid: a.created_by } : { null: true }},
|
||||
${{ date: a.updated_at }},
|
||||
${a.updated_by ? { uuid: a.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "resident": {
|
||||
const r = data as Resident;
|
||||
db.run(sql`
|
||||
INSERT INTO "residents"("uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: r.uid }},
|
||||
${{ uuid: r.aid }},
|
||||
${{ jsonb: r.flags }},
|
||||
${{ date: r.created_at }},
|
||||
${r.created_by ? { uuid: r.created_by } : { null: true }},
|
||||
${{ date: r.updated_at }},
|
||||
${r.updated_by ? { uuid: r.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "apikey": {
|
||||
const k = data as ClientApikey;
|
||||
// `token` is never exported; mint a fresh unique one so the record's
|
||||
// metadata (kid/permissions/expiry) survives even though the original
|
||||
// secret cannot.
|
||||
db.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "created_at", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: k.uid }},
|
||||
${{ uuid: k.kid }},
|
||||
${{ text: createApikeyToken() }},
|
||||
${{ text: k.name }},
|
||||
${{ jsonb: k.permissions }},
|
||||
${{ date: k.created_at }},
|
||||
${k.expires_at ? { date: k.expires_at } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "note": {
|
||||
const n = data as Note;
|
||||
db.run(sql`
|
||||
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: n.nid }},
|
||||
${{ uuid: n.aid }},
|
||||
${{ text: n.name }},
|
||||
${{ text: n.content ?? "" }},
|
||||
${{ jsonb: n.properties }},
|
||||
${{ date: n.created_at }},
|
||||
${n.created_by ? { uuid: n.created_by } : { null: true }},
|
||||
${{ date: n.updated_at }},
|
||||
${n.updated_by ? { uuid: n.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,11 @@ export class SqliteMigrator implements Migrator {
|
||||
}
|
||||
|
||||
#listAppliedMigrations():
|
||||
| { id: number; name: string; applied_at: string }[]
|
||||
| null {
|
||||
{ id: number; name: string; applied_at: string }[] | null {
|
||||
try {
|
||||
return this.#db
|
||||
.all<{ id: number; name: string; applied_at: string }>(
|
||||
sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`
|
||||
sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`,
|
||||
)
|
||||
.map((x) => ({ ...x, applied_at: sqliteToDate(x.applied_at) }));
|
||||
} catch (e) {
|
||||
@@ -59,7 +58,7 @@ export class SqliteMigrator implements Migrator {
|
||||
throw new Error(`Applied migration ${id} (${name}) not known`);
|
||||
if (migration.name !== name)
|
||||
throw new Error(
|
||||
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`
|
||||
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +69,7 @@ export class SqliteMigrator implements Migrator {
|
||||
throw new Error(
|
||||
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${
|
||||
target.id
|
||||
}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +93,7 @@ export class SqliteMigrator implements Migrator {
|
||||
sql`
|
||||
INSERT INTO "_migrations"("id", "name")
|
||||
VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
|
||||
`
|
||||
`,
|
||||
);
|
||||
this.#db.run(sql`COMMIT`);
|
||||
} catch (e) {
|
||||
|
||||
+77
-2
@@ -1,5 +1,12 @@
|
||||
import type { Abode } from "../types/Abode.js";
|
||||
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
|
||||
import type {
|
||||
Note,
|
||||
NoteProperties,
|
||||
NoteType,
|
||||
PartialNote,
|
||||
PartialNoteProperties,
|
||||
} from "../types/Note.js";
|
||||
import type { Resident, ResidentFlags } from "../types/Resident.js";
|
||||
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
|
||||
|
||||
@@ -15,7 +22,7 @@ export function uuidToSqlite(uuid: string) {
|
||||
}
|
||||
export function sqliteToUuid(uuid: Buffer | Uint8Array) {
|
||||
const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString(
|
||||
"hex"
|
||||
"hex",
|
||||
);
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
@@ -119,7 +126,7 @@ export function sqliteToResident(resident: {
|
||||
|
||||
const defaultApikeyPermissions: ApikeyPermissions = {};
|
||||
export function sqliteToApikeyPermissions(
|
||||
permissions: string
|
||||
permissions: string,
|
||||
): ApikeyPermissions {
|
||||
const parsed = JSON.parse(permissions);
|
||||
const out = { ...defaultApikeyPermissions };
|
||||
@@ -160,3 +167,71 @@ export function sqliteToClientApikey(apikey: {
|
||||
expires_at: apikey.expires_at ? sqliteToDate(apikey.expires_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const validNoteTypes = new Set<NoteType>(["note"]);
|
||||
export function sqliteToNoteProperties(props: string): NoteProperties {
|
||||
const parsed = JSON.parse(props);
|
||||
const out: NoteProperties = {};
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed &&
|
||||
!Array.isArray(parsed) &&
|
||||
validNoteTypes.has(parsed.type)
|
||||
) {
|
||||
out.type = parsed.type;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sqliteToPartialNoteProperties(
|
||||
props: string,
|
||||
): PartialNoteProperties {
|
||||
const base = sqliteToNoteProperties(props);
|
||||
return { type: base.type ?? "note" };
|
||||
}
|
||||
|
||||
export function sqliteToNote(note: {
|
||||
nid: Buffer | Uint8Array;
|
||||
aid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
content: string;
|
||||
properties: string;
|
||||
created_at: string;
|
||||
created_by: Buffer | Uint8Array | null;
|
||||
updated_at: string;
|
||||
updated_by: Buffer | Uint8Array | null;
|
||||
}): Note {
|
||||
return {
|
||||
nid: sqliteToUuid(note.nid),
|
||||
aid: sqliteToUuid(note.aid),
|
||||
name: note.name,
|
||||
content: note.content,
|
||||
properties: sqliteToNoteProperties(note.properties),
|
||||
created_at: sqliteToDate(note.created_at),
|
||||
created_by: note.created_by ? sqliteToUuid(note.created_by) : null,
|
||||
updated_at: sqliteToDate(note.updated_at),
|
||||
updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function sqliteToPartialNote(note: {
|
||||
nid: Buffer | Uint8Array;
|
||||
aid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
properties: string;
|
||||
created_at: string;
|
||||
created_by: Buffer | Uint8Array | null;
|
||||
updated_at: string;
|
||||
updated_by: Buffer | Uint8Array | null;
|
||||
}): PartialNote {
|
||||
return {
|
||||
nid: sqliteToUuid(note.nid),
|
||||
aid: sqliteToUuid(note.aid),
|
||||
name: note.name,
|
||||
properties: sqliteToPartialNoteProperties(note.properties),
|
||||
created_at: sqliteToDate(note.created_at),
|
||||
created_by: note.created_by ? sqliteToUuid(note.created_by) : null,
|
||||
updated_at: sqliteToDate(note.updated_at),
|
||||
updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { sqliteProtocols } from "./url.js";
|
||||
|
||||
const getSqlite = () =>
|
||||
import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then(
|
||||
(x) => x.default
|
||||
(x) => x.default,
|
||||
);
|
||||
|
||||
const getSqliteDynamic: GetDbDynamic = {
|
||||
|
||||
@@ -6,10 +6,11 @@ import type { WrappedDb, WrappedDbOptions } from "./types.js";
|
||||
|
||||
function getDatabase(
|
||||
path: string,
|
||||
options?: Omit<sqlite.Options, "nativeBinding">
|
||||
options?: Omit<sqlite.Options, "nativeBinding">,
|
||||
): sqlite.Database {
|
||||
if (!natives.sqlite) throw new Error("No natives found for better-sqlite3");
|
||||
options = { ...options };
|
||||
if (typeof options.readonly !== "boolean") delete options.readonly;
|
||||
if (typeof options.timeout !== "number") delete options.timeout;
|
||||
const db = new Sqlite(path, { ...options, nativeBinding: natives.sqlite });
|
||||
db.exec(pragma);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { node, bs3 } from "./implementations.js";
|
||||
export function getWrappedDb(
|
||||
kind: "any" | "node" | "bs3",
|
||||
path: string,
|
||||
options: WrappedDbOptions
|
||||
options: WrappedDbOptions,
|
||||
): WrappedDb {
|
||||
if (kind === "node") {
|
||||
if (!node) throw new Error("Requesting unavailable node backend");
|
||||
|
||||
+56
-8
@@ -1,11 +1,14 @@
|
||||
import type { Abode } from "../types/Abode.js";
|
||||
import type { ClientApikey } from "../types/Apikey.js";
|
||||
import type { Note, PartialNote } from "../types/Note.js";
|
||||
import type { Resident } from "../types/Resident.js";
|
||||
import type { ClientUser } from "../types/User.js";
|
||||
import {
|
||||
sqliteToAbode,
|
||||
sqliteToClientApikey,
|
||||
sqliteToClientUser,
|
||||
sqliteToNote,
|
||||
sqliteToPartialNote,
|
||||
sqliteToResident,
|
||||
} from "./cast.js";
|
||||
import type { WrappedDb } from "./impl/types.js";
|
||||
@@ -26,7 +29,7 @@ const sqlClientUser = sql`
|
||||
|
||||
export function selectClientUser(
|
||||
db: WrappedDb,
|
||||
where: SqlCode
|
||||
where: SqlCode,
|
||||
): ClientUser | null {
|
||||
const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
|
||||
if (rawUser) return sqliteToClientUser(rawUser);
|
||||
@@ -34,7 +37,7 @@ export function selectClientUser(
|
||||
}
|
||||
export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] {
|
||||
const rawUsers = db.all<RawClientUser>(
|
||||
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser
|
||||
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
|
||||
);
|
||||
return rawUsers.map(sqliteToClientUser);
|
||||
}
|
||||
@@ -59,7 +62,7 @@ export function selectAbode(db: WrappedDb, where: SqlCode): Abode | null {
|
||||
}
|
||||
export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] {
|
||||
const rawAbodes = db.all<RawAbode>(
|
||||
rest ? sql`${sqlAbode} ${rest}` : sqlAbode
|
||||
rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
|
||||
);
|
||||
return rawAbodes.map(sqliteToAbode);
|
||||
}
|
||||
@@ -85,7 +88,7 @@ export function selectResident(db: WrappedDb, where: SqlCode): Resident | null {
|
||||
}
|
||||
export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] {
|
||||
const rawResidents = db.all<RawResident>(
|
||||
where ? sql`${sqlResident} WHERE ${where}` : sqlResident
|
||||
where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
|
||||
);
|
||||
return rawResidents.map(sqliteToResident);
|
||||
}
|
||||
@@ -105,20 +108,65 @@ const sqlClientApikey = sql`
|
||||
|
||||
export function selectClientApikey(
|
||||
db: WrappedDb,
|
||||
where: SqlCode
|
||||
where: SqlCode,
|
||||
): ClientApikey | null {
|
||||
const rawApikey = db.get<RawClientApikey>(
|
||||
sql`${sqlClientApikey} WHERE ${where}`
|
||||
sql`${sqlClientApikey} WHERE ${where}`,
|
||||
);
|
||||
if (rawApikey) return sqliteToClientApikey(rawApikey);
|
||||
return null;
|
||||
}
|
||||
export function selectClientApikeys(
|
||||
db: WrappedDb,
|
||||
where: SqlCode
|
||||
where?: SqlCode,
|
||||
): ClientApikey[] {
|
||||
const rawApikeys = db.all<RawClientApikey>(
|
||||
sql`${sqlClientApikey} WHERE ${where}`
|
||||
where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey,
|
||||
);
|
||||
return rawApikeys.map(sqliteToClientApikey);
|
||||
}
|
||||
|
||||
type RawNote = {
|
||||
nid: Buffer | Uint8Array;
|
||||
aid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
content: string;
|
||||
properties: string;
|
||||
created_at: string;
|
||||
created_by: Buffer | Uint8Array | null;
|
||||
updated_at: string;
|
||||
updated_by: Buffer | Uint8Array | null;
|
||||
};
|
||||
const sqlNote = sql`
|
||||
SELECT n."nid", n."aid", n."name", n."content", json(n."properties") AS "properties",
|
||||
n."created_at", n."created_by", n."updated_at", n."updated_by"
|
||||
FROM "notes" n
|
||||
`;
|
||||
|
||||
type RawPartialNote = Omit<RawNote, "content">;
|
||||
const sqlPartialNote = sql`
|
||||
SELECT n."nid", n."aid", n."name", json(n."properties") AS "properties",
|
||||
n."created_at", n."created_by", n."updated_at", n."updated_by"
|
||||
FROM "notes" n
|
||||
`;
|
||||
|
||||
export function selectNote(db: WrappedDb, where: SqlCode): Note | null {
|
||||
const raw = db.get<RawNote>(sql`${sqlNote} WHERE ${where}`);
|
||||
if (raw) return sqliteToNote(raw);
|
||||
return null;
|
||||
}
|
||||
export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] {
|
||||
const raws = db.all<RawNote>(
|
||||
where ? sql`${sqlNote} WHERE ${where}` : sqlNote,
|
||||
);
|
||||
return raws.map(sqliteToNote);
|
||||
}
|
||||
export function selectPartialNotes(
|
||||
db: WrappedDb,
|
||||
where?: SqlCode,
|
||||
): PartialNote[] {
|
||||
const raws = db.all<RawPartialNote>(
|
||||
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote,
|
||||
);
|
||||
return raws.map(sqliteToPartialNote);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function calcUpdates<T extends object>(updater: {
|
||||
for (const [prop, update] of Object.entries(updater)) {
|
||||
if (prop in obj) {
|
||||
updates.push(
|
||||
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!)
|
||||
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function isSqliteUrl(url: string) {
|
||||
}
|
||||
|
||||
export function parseSqliteUrl(
|
||||
url: string
|
||||
url: string,
|
||||
): ["any" | "node" | "bs3", string, WrappedDbOptions] {
|
||||
if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol");
|
||||
const urlObj = new URL(url);
|
||||
@@ -39,7 +39,7 @@ export function parseSqliteUrl(
|
||||
urlObj.protocol === "node+sqlite:"
|
||||
? "node"
|
||||
: urlObj.protocol === "bs3+sqlite:"
|
||||
? "bs3"
|
||||
: "any";
|
||||
? "bs3"
|
||||
: "any";
|
||||
return [kind, urlObj.pathname, options];
|
||||
}
|
||||
|
||||
@@ -40,11 +40,11 @@ export interface DbInterface {
|
||||
deleteResidentById(uid: string, aid: string): Promise<void>;
|
||||
createResident(
|
||||
resident: CreateResident,
|
||||
ctx: { uid: string }
|
||||
ctx: { uid: string },
|
||||
): Promise<Resident>;
|
||||
updateResident(
|
||||
resident: updateResident,
|
||||
ctx: { uid: string }
|
||||
ctx: { uid: string },
|
||||
): Promise<Resident>;
|
||||
|
||||
// list residents by member
|
||||
@@ -85,6 +85,7 @@ export interface BackendDbInterface extends DbInterface {
|
||||
// auth by session
|
||||
getUserBySession(token: `as_${string}`): Promise<ClientUser>;
|
||||
createSession(uid: string): Promise<`as_${string}`>;
|
||||
deleteSession(token: `as_${string}`): Promise<void>;
|
||||
|
||||
// auth by apikey
|
||||
getUserByApikey(token: `at_${string}`): Promise<[ClientUser, ClientApikey]>;
|
||||
@@ -98,11 +99,12 @@ export function isBackendInterface(db: DbInterface): db is BackendDbInterface {
|
||||
"getUserByLogin",
|
||||
"getUserBySession",
|
||||
"createSession",
|
||||
"deleteSession",
|
||||
"getUserByApikey",
|
||||
] as const
|
||||
).every(
|
||||
(x) =>
|
||||
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function"
|
||||
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DbInterface } from "./DbInterface.js";
|
||||
|
||||
/**
|
||||
* The kinds of records that can travel through an export/import stream.
|
||||
*
|
||||
* `Session` is intentionally excluded: it has no CRUD/list surface in
|
||||
* {@link DbInterface} and is ephemeral/non-portable between instances.
|
||||
*/
|
||||
export type ExportKind = "user" | "abode" | "resident" | "apikey" | "note";
|
||||
|
||||
/**
|
||||
* Canonical order in which record kinds are emitted into an export stream, and
|
||||
* the order in which an importer may safely apply them.
|
||||
*
|
||||
* This ordering is **part of the wire contract**, not an implementation
|
||||
* detail. It is FK-safe: every foreign key points only at a kind that appears
|
||||
* earlier (or at the same kind, earlier in the stream), so an importer that
|
||||
* inserts records one-by-one with referential integrity enforced never
|
||||
* forward-references a row it hasn't inserted yet:
|
||||
*
|
||||
* - `abode.created_by`/`updated_by` → `user`
|
||||
* - `resident.uid` → `user`, `resident.aid` → `abode`
|
||||
* - `apikey.uid` → `user`
|
||||
* - `note.aid` → `abode`, `note.created_by`/`updated_by` → `user`
|
||||
*
|
||||
* The sqlite backend can afford to relax this (it suspends FK enforcement for
|
||||
* the load), but the postgres backend relies on it: it inserts sequentially
|
||||
* with constraints live. Every {@link Exportable} MUST emit in this order, and
|
||||
* reordering it is a breaking change to the format.
|
||||
*/
|
||||
export const EXPORT_KIND_ORDER = [
|
||||
"user",
|
||||
"abode",
|
||||
"resident",
|
||||
"apikey",
|
||||
"note",
|
||||
] as const satisfies readonly ExportKind[];
|
||||
|
||||
export type ExportFilter = {
|
||||
/** Include only these kinds; omit = all kinds. */
|
||||
kinds?: ExportKind[];
|
||||
/** Excluded after `kinds` is applied. */
|
||||
excludeKinds?: ExportKind[];
|
||||
/** aid allowlist — scopes abode/resident/note. */
|
||||
abodes?: string[];
|
||||
/** uid allowlist — scopes user (and apikey, unless `apikeys` is set). */
|
||||
users?: string[];
|
||||
/**
|
||||
* uid allowlist scoping apikey records specifically. When set it takes
|
||||
* precedence over `users` for the `apikey` kind — used to export a
|
||||
* non-admin's own keys while still exporting co-residents' *user* records
|
||||
* for referential integrity, without leaking their apikey metadata. Absent =
|
||||
* fall back to `users`.
|
||||
*/
|
||||
apikeys?: string[];
|
||||
};
|
||||
|
||||
export interface ExportOptions {
|
||||
filter?: ExportFilter;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface Exportable {
|
||||
/** Produce a stream of NDJSON lines (one JSON envelope per line). */
|
||||
export(options?: ExportOptions): NodeJS.ReadableStream;
|
||||
}
|
||||
|
||||
export interface ImportOptions {
|
||||
filter?: ExportFilter;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
counts: Partial<Record<ExportKind, number>>;
|
||||
}
|
||||
|
||||
export interface Importable {
|
||||
import(
|
||||
source: NodeJS.ReadableStream,
|
||||
options?: ImportOptions,
|
||||
): Promise<ImportResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The NDJSON envelope written/read for every line. The leading line is a
|
||||
* `meta` record; the record lines that follow are grouped by kind in
|
||||
* {@link EXPORT_KIND_ORDER} (an FK-safe order importers may rely on); a
|
||||
* trailing `error` record may appear if the source failed after streaming had
|
||||
* already begun.
|
||||
*/
|
||||
export type ExportEnvelope =
|
||||
| { kind: "meta"; data: ExportMeta }
|
||||
| { kind: ExportKind; data: unknown }
|
||||
| { kind: "error"; data: { message: string; code?: string } };
|
||||
|
||||
export type ExportMeta = {
|
||||
v: number;
|
||||
exportedAt: string;
|
||||
source: string;
|
||||
/** The *effective* filter actually applied (may be narrower than requested). */
|
||||
filter: ExportFilter;
|
||||
};
|
||||
|
||||
export function isExportable(db: DbInterface): db is DbInterface & Exportable {
|
||||
return typeof (db as Partial<Exportable>).export === "function";
|
||||
}
|
||||
|
||||
export function isImportable(db: DbInterface): db is DbInterface & Importable {
|
||||
return typeof (db as Partial<Importable>).import === "function";
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type LoginUser = {
|
||||
};
|
||||
|
||||
export function isValidUserPassword(
|
||||
password: string
|
||||
password: string,
|
||||
): password is User["password"] {
|
||||
if (password.startsWith("#")) {
|
||||
return ["unset"].includes(password.slice(1));
|
||||
|
||||
@@ -2,7 +2,7 @@ if (import.meta.hot) {
|
||||
import.meta.hot.on("message", (msg) => {
|
||||
if (
|
||||
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");
|
||||
|
||||
@@ -32,23 +32,23 @@ async function getPackageJsonDir(path: string): Promise<string | null> {
|
||||
|
||||
export async function findNative(
|
||||
module: string,
|
||||
native: string
|
||||
native: string,
|
||||
): Promise<string> {
|
||||
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}`);
|
||||
const file = await find(path, native);
|
||||
if (!file)
|
||||
throw new Error(
|
||||
`Cannot find native ${native} of package ${module} in ${path}`
|
||||
`Cannot find native ${native} of package ${module} in ${path}`,
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function tryFindNative(
|
||||
module: string,
|
||||
native: string
|
||||
native: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await findNative(module, native);
|
||||
|
||||
@@ -22,8 +22,8 @@ export async function webpack(): Promise<{ code: string }> {
|
||||
let code: string = standaloneCode(
|
||||
validator,
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface PopupManagerContextData {
|
||||
openPopup(popup: ComponentType<{ id: string; onClose: () => void }>): string;
|
||||
openPopup<T>(
|
||||
popup: ComponentType<{ id: string; onClose: () => void } & T>,
|
||||
props: T
|
||||
props: T,
|
||||
): string;
|
||||
|
||||
closePopup(id: string): void;
|
||||
@@ -34,7 +34,7 @@ export function PopupManager({ children }: { children: ReactNode }) {
|
||||
const openPopup = useCallback<PopupManagerContextData["openPopup"]>(
|
||||
(
|
||||
Component: ComponentType<{ id: string; onClose: () => void }>,
|
||||
props = {}
|
||||
props = {},
|
||||
) => {
|
||||
const id = crypto.randomUUID();
|
||||
Object.assign(props, {
|
||||
@@ -45,14 +45,14 @@ export function PopupManager({ children }: { children: ReactNode }) {
|
||||
setPopups((prev) => [...prev, { id, Component, props }]);
|
||||
return id;
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
const closePopup = useCallback((id: string) => {
|
||||
setPopups((prev) => prev.filter((x) => x.id !== id));
|
||||
}, []);
|
||||
const ctx = useMemo<PopupManagerContextData>(
|
||||
() => ({ openPopup, closePopup }),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useDataResidentsByAbodeId(aid: string) {
|
||||
const status = useLoad(loadResidentsByAbodeId, { aid });
|
||||
const residents = useMemo(
|
||||
() => Object.values(allResidents).filter((x) => x.aid === aid),
|
||||
[allResidents, aid]
|
||||
[allResidents, aid],
|
||||
);
|
||||
return { ...status, residents };
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function useDataResidentsByUserId(uid: string) {
|
||||
const status = useLoad(loadResidentsByUserId, { uid });
|
||||
const residents = useMemo(
|
||||
() => Object.values(allResidents).filter((x) => x.uid === uid),
|
||||
[allResidents, uid]
|
||||
[allResidents, uid],
|
||||
);
|
||||
return { ...status, residents };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Store } from "../store/store.js";
|
||||
import { useStore } from "../store/react.js";
|
||||
|
||||
export function useAction<P extends any[], R>(
|
||||
action: (...params: [...P, { db: DbInterface; store: Store }]) => Promise<R>
|
||||
action: (...params: [...P, { db: DbInterface; store: Store }]) => Promise<R>,
|
||||
): (...args: P) => Promise<R> {
|
||||
const db = use(DbContext);
|
||||
const store = useStore();
|
||||
@@ -15,6 +15,6 @@ export function useAction<P extends any[], R>(
|
||||
if (!db) throw new Error("DB not present");
|
||||
return action(...params, { db, store });
|
||||
},
|
||||
[action, db]
|
||||
[action, db],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useLoad<P>(loader: Loader<P>, params?: P): UseLoadResult {
|
||||
|
||||
const refresh = useCallback(
|
||||
() => load({ loader, params: params!, store, db, refresh: true }),
|
||||
[loader, params, db]
|
||||
[loader, params, db],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Store } from "../store.js";
|
||||
|
||||
export async function deleteAbodeById(
|
||||
aid: string,
|
||||
{ store, db }: { store: Store; db: DbInterface }
|
||||
{ store, db }: { store: Store; db: DbInterface },
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
waitForLoadIfLoading(store, "loadAllAbodes"),
|
||||
@@ -22,7 +22,7 @@ export async function deleteAbodeById(
|
||||
|
||||
export async function updateAbode(
|
||||
abode: UpdateAbode,
|
||||
{ store, db }: { store: Store; db: DbInterface }
|
||||
{ store, db }: { store: Store; db: DbInterface },
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
waitForLoadIfLoading(store, "loadAllAbodes"),
|
||||
@@ -38,7 +38,7 @@ export async function updateAbode(
|
||||
|
||||
export async function createAbode(
|
||||
abode: CreateAbode,
|
||||
{ store, db }: { store: Store; db: DbInterface }
|
||||
{ store, db }: { store: Store; db: DbInterface },
|
||||
): Promise<string> {
|
||||
await waitForLoadIfLoading(store, "loadAllAbodes");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Store } from "../store.js";
|
||||
|
||||
export async function deleteUserById(
|
||||
uid: string,
|
||||
{ store, db }: { store: Store; db: DbInterface }
|
||||
{ store, db }: { store: Store; db: DbInterface },
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
waitForLoadIfLoading(store, "loadAllUsers"),
|
||||
@@ -24,7 +24,7 @@ export async function deleteUserById(
|
||||
|
||||
export async function updateUser(
|
||||
user: UpdateUser,
|
||||
{ store, db }: { store: Store; db: DbInterface }
|
||||
{ store, db }: { store: Store; db: DbInterface },
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
waitForLoadIfLoading(store, "loadAllUsers"),
|
||||
@@ -41,7 +41,7 @@ export async function updateUser(
|
||||
|
||||
export async function createUser(
|
||||
user: CreateUser,
|
||||
{ store, db }: { store: Store; db: DbInterface }
|
||||
{ store, db }: { store: Store; db: DbInterface },
|
||||
): Promise<string> {
|
||||
await waitForLoadIfLoading(store, "loadAllUsers");
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ async function loadImpl<P>({
|
||||
setLoading([
|
||||
id,
|
||||
{ status: refresh ? "refreshing" : "loading", type, params },
|
||||
])
|
||||
]),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
@@ -69,7 +69,10 @@ async function loadImpl<P>({
|
||||
store.dispatch(setLoading([id, { status: "loaded", type, params }]));
|
||||
} catch (e) {
|
||||
store.dispatch(
|
||||
setLoading([id, { status: "error", type, params, error: objectError(e) }])
|
||||
setLoading([
|
||||
id,
|
||||
{ status: "error", type, params, error: objectError(e) },
|
||||
]),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
@@ -114,7 +117,7 @@ export function loader<P>(loader: Loader<P>): Loader<P> {
|
||||
export async function waitForLoadIfLoading(
|
||||
store: Store,
|
||||
id: string,
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
{ signal }: { signal?: AbortSignal } = {},
|
||||
) {
|
||||
if (!getLoadingStatus(store.getState(), id)) return;
|
||||
return waitFor(
|
||||
@@ -123,6 +126,6 @@ export async function waitForLoadIfLoading(
|
||||
const status = getLoadingStatus(state, id);
|
||||
return status === "loaded" || status === "error";
|
||||
},
|
||||
{ signal }
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Provider(
|
||||
props: Omit<ProviderProps, "context" | "store" | "serverState"> & {
|
||||
store: Store;
|
||||
serverState?: State;
|
||||
}
|
||||
},
|
||||
) {
|
||||
return <RawProvider context={AbodeStoreContext} {...props} />;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const loadingSlice = createSlice({
|
||||
reducers: {
|
||||
setLoading: (
|
||||
state,
|
||||
action: PayloadAction<[id: string, state: LoadingState]>
|
||||
action: PayloadAction<[id: string, state: LoadingState]>,
|
||||
) => {
|
||||
state[action.payload[0]] = action.payload[1];
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ const usersSlice = createSlice({
|
||||
getUser: usersSelectors.selectById,
|
||||
getUserByEmail: (state, email: string) =>
|
||||
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,
|
||||
getUserIds: usersSelectors.selectIds,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { State, Store } from "./store.js";
|
||||
export async function waitFor(
|
||||
store: Store,
|
||||
cond: (state: State) => boolean,
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
{ signal }: { signal?: AbortSignal } = {},
|
||||
): Promise<void> {
|
||||
return new Promise<void>((ok, ko) => {
|
||||
signal?.throwIfAborted();
|
||||
@@ -28,7 +28,7 @@ export async function waitFor(
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+19
-19
@@ -1,23 +1,23 @@
|
||||
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 updateuser } from "./user/updateuser.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 userflags } from "./user/userflags.schema.json" with {type: 'json'};
|
||||
export { default as loginuser } from "./user/loginuser.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 updateuser } from "./user/updateuser.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 userflags } from "./user/userflags.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 createabode } from './abode/createabode.schema.json' with {type: 'json'};
|
||||
export { default as updateabode } from './abode/updateabode.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 updateabode } from "./abode/updateabode.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 updateresident } from './resident/updateresident.schema.json' with {type: 'json'};
|
||||
export { default as residentflags } from './resident/residentflags.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 updateresident } from "./resident/updateresident.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 apikeypermissions } from './apikey/apikeypermissions.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 createnote } from './note/createnote.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 createnote } from "./note/createnote.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" };
|
||||
|
||||
@@ -39,7 +39,7 @@ function checkSchema(name: string, schema: AnySchema) {
|
||||
if (!schema.$id) throw new Error(`Missing $id for schema ${name}`);
|
||||
if (
|
||||
!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}`);
|
||||
|
||||
@@ -18,7 +18,7 @@ const validators = Object.fromEntries(
|
||||
Object.entries(schemas).map(([name, schema]) => [
|
||||
name,
|
||||
validator.compile(schema),
|
||||
])
|
||||
]),
|
||||
) as unknown as {
|
||||
[T in keyof Types]: {
|
||||
(obj: unknown): obj is Types[T];
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ function App() {
|
||||
(input, key) => {
|
||||
if (input === "q" || key.escape) app.exit();
|
||||
},
|
||||
{ isActive }
|
||||
{ isActive },
|
||||
);
|
||||
|
||||
const [activeCollection, setActiveCollection] =
|
||||
|
||||
@@ -41,11 +41,11 @@ export function AbodesPanel() {
|
||||
|
||||
const onSelect = useCallback(
|
||||
(abode: Abode) => openPopup(AbodePopup, { aid: abode.aid }),
|
||||
[openPopup]
|
||||
[openPopup],
|
||||
);
|
||||
const buttons = useMemo<ButtonListItem[]>(
|
||||
() => [{ children: "New", onClick: () => openPopup(CreateAbodePopup) }],
|
||||
[openPopup]
|
||||
[openPopup],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,11 +59,11 @@ export function UsersPanel() {
|
||||
|
||||
const onSelect = useCallback(
|
||||
(user: ClientUser | PartialUser) => openPopup(UserPopup, { uid: user.uid }),
|
||||
[openPopup]
|
||||
[openPopup],
|
||||
);
|
||||
const buttons = useMemo<ButtonListItem[]>(
|
||||
() => [{ children: "New", onClick: () => openPopup(CreateUserPopup) }],
|
||||
[openPopup]
|
||||
[openPopup],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,7 @@ export function Button({
|
||||
(input, key) => {
|
||||
if (input === " " || key.return) onClick();
|
||||
},
|
||||
{ isActive: isFocused }
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
return <Text inverse={isFocused}>[{children}]</Text>;
|
||||
@@ -66,7 +66,7 @@ export function ButtonList({
|
||||
setSelected((prev) => (prev - 1 + buttons.length) % buttons.length);
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused || forceFocus || false }
|
||||
{ isActive: isFocused || forceFocus || false },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ListBox<T extends string>({
|
||||
setSelected(items[(items.indexOf(selected) + 1) % items.length]);
|
||||
} else if (key.upArrow) {
|
||||
setSelected(
|
||||
items[(items.indexOf(selected) - 1 + items.length) % items.length]
|
||||
items[(items.indexOf(selected) - 1 + items.length) % items.length],
|
||||
);
|
||||
} else if (key.pageUp) {
|
||||
setSelected(items[0]);
|
||||
@@ -57,7 +57,7 @@ export function ListBox<T extends string>({
|
||||
setSelected(items[items.length - 1]);
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused }
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,14 +36,14 @@ export function ListDisplay<T>({
|
||||
onSelect?.(items[selected]);
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused }
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected < start) setStart(selected);
|
||||
else if (selected >= slice)
|
||||
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]);
|
||||
useEffect(() => {
|
||||
@@ -56,7 +56,7 @@ export function ListDisplay<T>({
|
||||
|
||||
const indexed = useMemo(
|
||||
() => items.map((item, index) => ({ item, index })),
|
||||
[items]
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Popup({
|
||||
(_, key) => {
|
||||
if (key.escape) onClose?.();
|
||||
},
|
||||
{ isActive: active && !!onClose }
|
||||
{ isActive: active && !!onClose },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -52,7 +52,7 @@ export function SearchPanel<T>({
|
||||
refresh?.();
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused && !!refresh }
|
||||
{ isActive: isFocused && !!refresh },
|
||||
);
|
||||
|
||||
const topbar = !!match || !!buttons?.length;
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@ import { argon2id, argon2Verify } from "hash-wasm";
|
||||
|
||||
export async function validatePassword(
|
||||
password: string,
|
||||
hash: string
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
return await argon2Verify({ password, hash });
|
||||
}
|
||||
|
||||
export async function hashPassword(
|
||||
password: string
|
||||
password: string,
|
||||
): Promise<`$${string}$${string}`> {
|
||||
const salt = new Uint8Array(16);
|
||||
crypto.getRandomValues(salt);
|
||||
|
||||
+12
-14
@@ -7,11 +7,11 @@ const escapes = {
|
||||
};
|
||||
|
||||
function parseAdd<T>(
|
||||
rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[]
|
||||
rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[],
|
||||
): [
|
||||
props?: Record<string, string>,
|
||||
content?: string,
|
||||
children?: (writer: XmlWriter) => T
|
||||
children?: (writer: XmlWriter) => T,
|
||||
] {
|
||||
let props: Record<string, string> | undefined;
|
||||
let children: ((writer: XmlWriter) => T) | undefined;
|
||||
@@ -55,7 +55,7 @@ export class XmlWriter {
|
||||
tag: string,
|
||||
props?: Record<string, string>,
|
||||
content?: string,
|
||||
children?: NonNullable<unknown>
|
||||
children?: NonNullable<unknown>,
|
||||
) {
|
||||
const top = this.#stack.at(-1);
|
||||
if (top && !top.children) {
|
||||
@@ -104,7 +104,7 @@ export class XmlWriter {
|
||||
add(
|
||||
tag: string,
|
||||
props: Record<string, string>,
|
||||
children: (writer: XmlWriter) => void
|
||||
children: (writer: XmlWriter) => void,
|
||||
): XmlWriter;
|
||||
add(
|
||||
tag: string,
|
||||
@@ -125,23 +125,21 @@ export class XmlWriter {
|
||||
addAsync(
|
||||
tag: string,
|
||||
props: Record<string, string>,
|
||||
content: string
|
||||
content: string,
|
||||
): Promise<void>;
|
||||
addAsync(
|
||||
tag: string,
|
||||
children: (writer: XmlWriter) => Promise<void>
|
||||
children: (writer: XmlWriter) => Promise<void>,
|
||||
): Promise<void>;
|
||||
addAsync(
|
||||
tag: string,
|
||||
props: Record<string, string>,
|
||||
children: (writer: XmlWriter) => Promise<void>
|
||||
children: (writer: XmlWriter) => Promise<void>,
|
||||
): Promise<void>;
|
||||
async addAsync(
|
||||
tag: string,
|
||||
...rest: (
|
||||
| Record<string, string>
|
||||
| string
|
||||
| ((writer: XmlWriter) => Promise<void>)
|
||||
Record<string, string> | string | ((writer: XmlWriter) => Promise<void>)
|
||||
)[]
|
||||
): Promise<void> {
|
||||
const [props, content, children] = parseAdd(rest);
|
||||
@@ -164,7 +162,7 @@ export class XmlWriter {
|
||||
| Parameters<InstanceType<typeof XmlWriter>["add"]>
|
||||
| [
|
||||
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
|
||||
...Parameters<InstanceType<typeof XmlWriter>["add"]>
|
||||
...Parameters<InstanceType<typeof XmlWriter>["add"]>,
|
||||
]
|
||||
): string {
|
||||
let options: ConstructorParameters<typeof XmlWriter>[0];
|
||||
@@ -172,7 +170,7 @@ export class XmlWriter {
|
||||
options = rest.shift()! as ConstructorParameters<typeof XmlWriter>[0];
|
||||
}
|
||||
return new XmlWriter(options).add(
|
||||
...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>)
|
||||
...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>),
|
||||
).content;
|
||||
}
|
||||
|
||||
@@ -188,7 +186,7 @@ export class XmlWriter {
|
||||
| Parameters<InstanceType<typeof XmlWriter>["addAsync"]>
|
||||
| [
|
||||
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
|
||||
...Parameters<InstanceType<typeof XmlWriter>["addAsync"]>
|
||||
...Parameters<InstanceType<typeof XmlWriter>["addAsync"]>,
|
||||
]
|
||||
): Promise<string> {
|
||||
let options: ConstructorParameters<typeof XmlWriter>[0];
|
||||
@@ -197,7 +195,7 @@ export class XmlWriter {
|
||||
}
|
||||
const writer = new XmlWriter(options);
|
||||
await writer.addAsync(
|
||||
...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>)
|
||||
...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>),
|
||||
);
|
||||
return writer.content;
|
||||
}
|
||||
|
||||
+106
-14
@@ -11,10 +11,43 @@ import {
|
||||
loginuser,
|
||||
updateabode,
|
||||
updateresident,
|
||||
updatenote,
|
||||
updateuser,
|
||||
} from "../schema/validators.js";
|
||||
import { authenticate } from "./middleware/authenticate.js";
|
||||
import { InvalidAbodeError, NotFoundAbodeError } from "../db/types/errors.js";
|
||||
import {
|
||||
InvalidAbodeError,
|
||||
NotAuthorizedAbodeError,
|
||||
NotFoundAbodeError,
|
||||
} from "../db/types/errors.js";
|
||||
import { isExportable } from "../db/types/ExportImport.js";
|
||||
import type { ExportFilter, ExportKind } from "../db/types/ExportImport.js";
|
||||
import { intersectExportFilters, isExportKind } from "../db/export/filter.js";
|
||||
import { computeForcedExportFilter } from "./exportScope.js";
|
||||
import {
|
||||
hasGlobalUserVisibility,
|
||||
hideUserEmail,
|
||||
userForCaller,
|
||||
} from "./userVisibility.js";
|
||||
|
||||
function parseExportFilter(query: Record<string, unknown>): ExportFilter {
|
||||
const list = (v: unknown): string[] | undefined => {
|
||||
if (typeof v !== "string" || !v) return undefined;
|
||||
return v.split(",").filter(Boolean);
|
||||
};
|
||||
const kinds = (v: unknown): ExportKind[] | undefined =>
|
||||
list(v)?.filter(isExportKind);
|
||||
const filter: ExportFilter = {};
|
||||
const k = kinds(query.kinds);
|
||||
if (k) filter.kinds = k;
|
||||
const ek = kinds(query.excludeKinds);
|
||||
if (ek) filter.excludeKinds = ek;
|
||||
const abodes = list(query.abodes);
|
||||
if (abodes) filter.abodes = abodes;
|
||||
const users = list(query.users);
|
||||
if (users) filter.users = users;
|
||||
return filter;
|
||||
}
|
||||
|
||||
export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
const router = new KoaRouter();
|
||||
@@ -28,6 +61,8 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
});
|
||||
router.post("/auth/logout", authenticate(db), async (ctx) => {
|
||||
if (ctx.session!.source !== "session") throw new InvalidAbodeError();
|
||||
const token = ctx.cookies.get("abode_session");
|
||||
if (token) await db.deleteSession(token as `as_${string}`);
|
||||
ctx.cookies.set("abode_session", "", { expires: new Date("1970-01-01") });
|
||||
ctx.status = 204;
|
||||
});
|
||||
@@ -39,26 +74,66 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
ctx.status = 204;
|
||||
});
|
||||
|
||||
router.get("/export", authenticate(db), async (ctx) => {
|
||||
// Everything that can throw a domain error runs *before* any byte is
|
||||
// written, so `convertError` still applies. Once `ctx.body` is a stream,
|
||||
// a mid-stream failure surfaces as a trailing `error` NDJSON line instead.
|
||||
const forced = await computeForcedExportFilter(db, {
|
||||
user: ctx.user!,
|
||||
session: ctx.session!,
|
||||
});
|
||||
const effective = intersectExportFilters(
|
||||
parseExportFilter(ctx.query),
|
||||
forced,
|
||||
);
|
||||
if (!isExportable(db)) {
|
||||
ctx.status = 501;
|
||||
ctx.body = { ok: false, error: "export_unsupported" };
|
||||
return;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
ctx.res.on("close", () => {
|
||||
if (!ctx.res.writableEnded) ac.abort();
|
||||
});
|
||||
ctx.type = "application/x-ndjson";
|
||||
ctx.body = db.export({ filter: effective, signal: ac.signal });
|
||||
});
|
||||
|
||||
router.use("/users", authenticate(db));
|
||||
router.get("/users", async (ctx) => {
|
||||
ctx.body = await db.listUsers();
|
||||
const users = await db.listUsers();
|
||||
ctx.body = users.map((user) =>
|
||||
userForCaller(user, { user: ctx.user!, session: ctx.session! }),
|
||||
);
|
||||
});
|
||||
router.post("/users", jsonBody({ validate: createuser }), async (ctx) => {
|
||||
ctx.body = await db.createUser(ctx.request.body);
|
||||
});
|
||||
router.get("/users/by-email", async (ctx) => {
|
||||
if (typeof ctx.query.email !== "string") throw new InvalidAbodeError();
|
||||
if (
|
||||
!hasGlobalUserVisibility({
|
||||
user: ctx.user!,
|
||||
session: ctx.session!,
|
||||
})
|
||||
) {
|
||||
throw new NotAuthorizedAbodeError();
|
||||
}
|
||||
ctx.body = await db.getUserByEmail(ctx.query.email);
|
||||
});
|
||||
router.get("/users/:uid", async (ctx) => {
|
||||
ctx.body = await db.getUserById(ctx.params.uid);
|
||||
const user = await db.getUserById(ctx.params.uid);
|
||||
ctx.body = userForCaller(user, {
|
||||
user: ctx.user!,
|
||||
session: ctx.session!,
|
||||
});
|
||||
});
|
||||
router.patch(
|
||||
"/users/:uid",
|
||||
jsonBody({ validate: updateuser, includeParams: ["uid"] }),
|
||||
async (ctx) => {
|
||||
ctx.body = await db.updateUser(ctx.request.body);
|
||||
}
|
||||
},
|
||||
);
|
||||
router.delete("/users/:uid", async (ctx) => {
|
||||
await db.deleteUserById(ctx.params.uid);
|
||||
@@ -79,7 +154,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
async (ctx) => {
|
||||
const [apikey, token] = await db.createApikey(ctx.request.body);
|
||||
ctx.body = { apikey, token };
|
||||
}
|
||||
},
|
||||
);
|
||||
router.get("/users/:uid/apikeys/:kid", async (ctx) => {
|
||||
const apikey = await db.getApikeyById(ctx.params.kid);
|
||||
@@ -92,11 +167,11 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
await db.deleteApikeyById(ctx.params.kid);
|
||||
ctx.status = 204;
|
||||
});
|
||||
router.post("/user/:uid/auth/clear-sessions", async (ctx) => {
|
||||
router.post("/users/:uid/auth/clear-sessions", async (ctx) => {
|
||||
await db.deleteSessionsByUser(ctx.params.uid);
|
||||
ctx.status = 204;
|
||||
});
|
||||
router.get("/user/:uid/notes", async (ctx) => {
|
||||
router.get("/users/:uid/notes", async (ctx) => {
|
||||
ctx.body = await db.listNotesByUserId(ctx.params.uid);
|
||||
});
|
||||
|
||||
@@ -115,7 +190,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
jsonBody({ validate: updateabode, includeParams: ["aid"] }),
|
||||
async (ctx) => {
|
||||
ctx.body = await db.updateAbode(ctx.request.body, { uid: ctx.user!.uid });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.delete("/abodes/:aid", async (ctx) => {
|
||||
await db.deleteAbodeById(ctx.params.aid);
|
||||
@@ -125,7 +200,24 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
ctx.body = await db.listResidentsByAbodeId(ctx.params.aid);
|
||||
});
|
||||
router.get("/abodes/:aid/users", async (ctx) => {
|
||||
ctx.body = await db.listUsersByAbodeId(ctx.params.aid);
|
||||
const users = await db.listUsersByAbodeId(ctx.params.aid);
|
||||
const globalVisibility = hasGlobalUserVisibility({
|
||||
user: ctx.user!,
|
||||
session: ctx.session!,
|
||||
});
|
||||
const residents = globalVisibility
|
||||
? []
|
||||
: await db.listResidentsByAbodeId(ctx.params.aid);
|
||||
const abodeAdmin = residents.some(
|
||||
(resident) =>
|
||||
resident.uid === ctx.user!.uid && resident.flags.admin === true,
|
||||
);
|
||||
ctx.body =
|
||||
globalVisibility || abodeAdmin
|
||||
? users
|
||||
: users.map((user) =>
|
||||
user.uid === ctx.user!.uid ? user : hideUserEmail(user),
|
||||
);
|
||||
});
|
||||
router.get("/abodes/:aid/notes", async (ctx) => {
|
||||
ctx.body = await db.listNotesByAbodeId(ctx.params.aid);
|
||||
@@ -135,7 +227,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
jsonBody({ validate: createnote, includeParams: ["aid"] }),
|
||||
async (ctx) => {
|
||||
ctx.body = await db.createNote(ctx.request.body, { uid: ctx.user!.uid });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.use("/residents", authenticate(db));
|
||||
@@ -149,7 +241,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
ctx.body = await db.createResident(ctx.request.body, {
|
||||
uid: ctx.user!.uid,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
router.get("/residents/:uid/:aid", async (ctx) => {
|
||||
ctx.body = await db.getResidentById(ctx.params.uid, ctx.params.aid);
|
||||
@@ -161,7 +253,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
ctx.body = await db.updateResident(ctx.request.body, {
|
||||
uid: ctx.user!.uid,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
router.delete("/residents/:uid/:aid", async (ctx) => {
|
||||
await db.deleteResidentById(ctx.params.uid, ctx.params.aid);
|
||||
@@ -186,10 +278,10 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
});
|
||||
router.patch(
|
||||
"/notes/:nid",
|
||||
jsonBody({ includeParams: ["nid"] }),
|
||||
jsonBody({ validate: updatenote, includeParams: ["nid"] }),
|
||||
async (ctx) => {
|
||||
ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.delete("/notes/:nid", async (ctx) => {
|
||||
await db.deleteNoteById(ctx.params.nid);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Context } from "koa";
|
||||
import type { BackendDbInterface } from "../db/types/DbInterface.js";
|
||||
import type { ClientUser } from "../db/types/User.js";
|
||||
import type { ExportFilter } from "../db/types/ExportImport.js";
|
||||
|
||||
/**
|
||||
* Compute the export scope that must be *forced* on a caller, independent of
|
||||
* anything they requested. Returns `null` when the caller is unrestricted (a
|
||||
* global admin whose credential imposes no narrowing) — their own filter, if
|
||||
* any, is then honored verbatim as a voluntary narrowing.
|
||||
*
|
||||
* Otherwise returns `{ abodes, users, apikeys }`: the abodes the caller resides
|
||||
* in, the users needed to keep that data referentially whole (the caller plus
|
||||
* every co-resident of those abodes), and — scoped tighter than `users` —
|
||||
* apikeys limited to the caller alone, so a non-admin never exports another
|
||||
* user's apikey metadata even though that user's record is included. This is
|
||||
* the maximum a non-admin may export; the route intersects it with any
|
||||
* caller-supplied filter (never a union).
|
||||
*/
|
||||
export async function computeForcedExportFilter(
|
||||
db: BackendDbInterface,
|
||||
ctx: { user: ClientUser; session: NonNullable<Context["session"]> },
|
||||
): Promise<ExportFilter | null> {
|
||||
const { user, session } = ctx;
|
||||
|
||||
if (user.flags.admin) {
|
||||
if (session.source !== "apikey") return null;
|
||||
const p = session.key.permissions;
|
||||
const unrestricted =
|
||||
!!p.admin &&
|
||||
!!p.all &&
|
||||
!p.restrict_users?.length &&
|
||||
!p.restrict_abodes?.length;
|
||||
if (unrestricted) return null;
|
||||
}
|
||||
|
||||
const residencies = await db.listResidentsByUserId(user.uid);
|
||||
const abodeSet = new Set(residencies.map((r) => r.aid));
|
||||
const userSet = new Set<string>([user.uid]);
|
||||
for (const aid of abodeSet) {
|
||||
for (const u of await db.listUsersByAbodeId(aid)) userSet.add(u.uid);
|
||||
}
|
||||
|
||||
let abodes = [...abodeSet];
|
||||
let users = [...userSet];
|
||||
// apikeys are self-only for non-admins, regardless of co-residency.
|
||||
let apikeys = [user.uid];
|
||||
|
||||
// An apikey can only narrow what its owning user could otherwise export.
|
||||
if (session.source === "apikey") {
|
||||
const p = session.key.permissions;
|
||||
if (p.restrict_abodes?.length) {
|
||||
const allow = new Set(p.restrict_abodes);
|
||||
abodes = abodes.filter((a) => allow.has(a));
|
||||
}
|
||||
if (p.restrict_users?.length) {
|
||||
const allow = new Set(p.restrict_users);
|
||||
users = users.filter((u) => allow.has(u));
|
||||
apikeys = apikeys.filter((u) => allow.has(u));
|
||||
}
|
||||
}
|
||||
|
||||
return { abodes, users, apikeys };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function schemarouter(): KoaRouter {
|
||||
url: `${ctx.URL}/${name}.schema.json`,
|
||||
},
|
||||
];
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Context } from "koa";
|
||||
import type { ClientUser, PartialUser } from "../db/types/User.js";
|
||||
|
||||
type AuthContext = {
|
||||
user: ClientUser;
|
||||
session: NonNullable<Context["session"]>;
|
||||
};
|
||||
|
||||
export function hasGlobalUserVisibility(ctx: AuthContext): boolean {
|
||||
if (!ctx.user.flags.admin) return false;
|
||||
if (ctx.session.source !== "apikey") return true;
|
||||
|
||||
const permissions = ctx.session.key.permissions;
|
||||
return (
|
||||
!!permissions.admin &&
|
||||
!!permissions.all &&
|
||||
!permissions.restrict_users?.length &&
|
||||
!permissions.restrict_abodes?.length
|
||||
);
|
||||
}
|
||||
|
||||
export function hideUserEmail(user: PartialUser | ClientUser): PartialUser {
|
||||
if (!("email" in user)) return user;
|
||||
const { email: _email, ...partial } = user;
|
||||
return partial;
|
||||
}
|
||||
|
||||
export function userForCaller(
|
||||
user: PartialUser | ClientUser,
|
||||
ctx: AuthContext,
|
||||
): PartialUser | ClientUser {
|
||||
if (user.uid === ctx.user.uid || hasGlobalUserVisibility(ctx)) {
|
||||
return user;
|
||||
}
|
||||
return hideUserEmail(user);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
|
||||
import {
|
||||
apiProtocols,
|
||||
isApiUrl,
|
||||
parseApiUrl,
|
||||
} from "../../../src/db/api/url.js";
|
||||
import {
|
||||
NotFoundAbodeError,
|
||||
NotAuthorizedAbodeError,
|
||||
ReadonlyAbodeError,
|
||||
InvalidAbodeError,
|
||||
ConflictAbodeError,
|
||||
} from "../../../src/db/types/errors.js";
|
||||
|
||||
describe("ApiInterface static properties", () => {
|
||||
it("name is 'api'", () => {
|
||||
const api = new ApiInterface("http://localhost:9999");
|
||||
assert.equal(api.name, "api");
|
||||
});
|
||||
|
||||
it("backend is false", () => {
|
||||
const api = new ApiInterface("http://localhost:9999");
|
||||
assert.equal(api.backend, false);
|
||||
});
|
||||
|
||||
it("readonly defaults to false", () => {
|
||||
const api = new ApiInterface("http://localhost:9999");
|
||||
assert.equal(api.readonly, false);
|
||||
});
|
||||
|
||||
it("readonly is set from options", () => {
|
||||
const api = new ApiInterface("http://localhost:9999", { readonly: true });
|
||||
assert.equal(api.readonly, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiProtocols and isApiUrl", () => {
|
||||
it("apiProtocols includes expected protocols", () => {
|
||||
assert.ok(apiProtocols.includes("https:"));
|
||||
assert.ok(apiProtocols.includes("http:"));
|
||||
assert.ok(apiProtocols.includes("abode+https:"));
|
||||
assert.ok(apiProtocols.includes("abode+http:"));
|
||||
});
|
||||
|
||||
it("isApiUrl returns true for http/https urls", () => {
|
||||
assert.equal(isApiUrl("http://example.com"), true);
|
||||
assert.equal(isApiUrl("https://example.com/api"), true);
|
||||
assert.equal(isApiUrl("abode+http://example.com"), true);
|
||||
assert.equal(isApiUrl("abode+https://example.com"), true);
|
||||
});
|
||||
|
||||
it("isApiUrl returns false for non-http urls", () => {
|
||||
assert.equal(isApiUrl("sqlite:///db.sqlite"), false);
|
||||
assert.equal(isApiUrl("not-a-url"), false);
|
||||
assert.equal(isApiUrl("ftp://example.com"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseApiUrl", () => {
|
||||
it("strips abode+ prefix from protocol", () => {
|
||||
const [root] = parseApiUrl("abode+http://example.com");
|
||||
assert.ok(root.startsWith("http://"), `expected http:// got ${root}`);
|
||||
});
|
||||
|
||||
it("extracts Basic auth from URL credentials", () => {
|
||||
const [, { headers }] = parseApiUrl("http://user:pass@example.com");
|
||||
assert.ok(headers["Authorization"]?.startsWith("Basic "), "has Basic auth");
|
||||
const decoded = atob(headers["Authorization"]!.slice("Basic ".length));
|
||||
assert.equal(decoded, "user:pass");
|
||||
});
|
||||
|
||||
it("strips credentials from root URL", () => {
|
||||
const [root] = parseApiUrl("http://user:pass@example.com");
|
||||
assert.ok(!root.includes("user"), "credentials stripped from root");
|
||||
});
|
||||
|
||||
it("extracts readonly flag from query", () => {
|
||||
const [, { readonly }] = parseApiUrl("http://example.com?readonly=1");
|
||||
assert.equal(readonly, true);
|
||||
});
|
||||
|
||||
it("defaults readonly to false", () => {
|
||||
const [, { readonly }] = parseApiUrl("http://example.com");
|
||||
assert.equal(readonly, false);
|
||||
});
|
||||
|
||||
it("extra query params become headers", () => {
|
||||
const [, { headers }] = parseApiUrl(
|
||||
"http://example.com?X-Custom-Header=value",
|
||||
);
|
||||
assert.equal(headers["X-Custom-Header"], "value");
|
||||
});
|
||||
|
||||
it("throws for non-api protocol", () => {
|
||||
assert.throws(
|
||||
() => parseApiUrl("sqlite:///db.sqlite"),
|
||||
/Not an \{abode\+,\}http\{s,\}: protocol/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiInterface HTTP error mapping", () => {
|
||||
let serverUrl: string;
|
||||
let closeServer: () => Promise<void>;
|
||||
let respondWith: (status: number) => void;
|
||||
|
||||
before(async () => {
|
||||
let nextStatus = 500;
|
||||
respondWith = (s) => {
|
||||
nextStatus = s;
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(nextStatus, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "test" }));
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
serverUrl = `http://127.0.0.1:${port}`;
|
||||
closeServer = () =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
|
||||
after(() => closeServer());
|
||||
|
||||
it("404 response throws NotFoundAbodeError", async () => {
|
||||
respondWith(404);
|
||||
const api = new ApiInterface(serverUrl);
|
||||
await assert.rejects(
|
||||
() => api.listUsers(),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("401 response throws NotAuthorizedAbodeError", async () => {
|
||||
respondWith(401);
|
||||
const api = new ApiInterface(serverUrl);
|
||||
await assert.rejects(
|
||||
() => api.listUsers(),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotAuthorizedAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("403 response throws ReadonlyAbodeError", async () => {
|
||||
respondWith(403);
|
||||
const api = new ApiInterface(serverUrl);
|
||||
await assert.rejects(
|
||||
() => api.listUsers(),
|
||||
(err) => {
|
||||
assert.ok(err instanceof ReadonlyAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("400 response throws InvalidAbodeError", async () => {
|
||||
respondWith(400);
|
||||
const api = new ApiInterface(serverUrl);
|
||||
await assert.rejects(
|
||||
() => api.listUsers(),
|
||||
(err) => {
|
||||
assert.ok(err instanceof InvalidAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("409 response throws ConflictAbodeError", async () => {
|
||||
respondWith(409);
|
||||
const api = new ApiInterface(serverUrl);
|
||||
await assert.rejects(
|
||||
() => api.listUsers(),
|
||||
(err) => {
|
||||
assert.ok(err instanceof ConflictAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiInterface._ internal helpers", () => {
|
||||
it("_.url builds correct URL for path params (no leftover query param)", () => {
|
||||
const api = new ApiInterface("http://example.com");
|
||||
const url = api._.url("/users/:uid", { uid: "abc-123" });
|
||||
assert.equal(url, "http://example.com/users/abc-123");
|
||||
});
|
||||
|
||||
it("_.url puts remaining params as query string", () => {
|
||||
const api = new ApiInterface("http://example.com");
|
||||
const url = api._.url("/users/by-email", { email: "a@b.com" });
|
||||
assert.ok(url.includes("email="), `expected query param in ${url}`);
|
||||
});
|
||||
|
||||
it("_.root matches the constructor argument", () => {
|
||||
const api = new ApiInterface("http://example.com/api");
|
||||
assert.equal(api._.root, "http://example.com/api");
|
||||
});
|
||||
|
||||
it("_.headers includes Authorization when set", () => {
|
||||
const api = new ApiInterface("http://example.com", {
|
||||
headers: { Authorization: "Basic dGVzdA==" },
|
||||
});
|
||||
assert.equal(api._.headers["Authorization"], "Basic dGVzdA==");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createTestDb } from "../../helpers/sqlite.js";
|
||||
import { createTestServer, type TestServer } from "../../helpers/koa.js";
|
||||
import { hashPassword } from "../../../src/util/hash.js";
|
||||
import type { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js";
|
||||
|
||||
const EMAIL = "auth-http@test.example";
|
||||
const PASSWORD = "auth-http-password";
|
||||
|
||||
function getCookie(res: Response, name: string): string | undefined {
|
||||
const raw = res.headers.getSetCookie?.() ?? [];
|
||||
for (const entry of raw) {
|
||||
const [pair] = entry.split(";");
|
||||
const [key, value] = pair.split("=");
|
||||
if (key === name) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("api backend: auth over HTTP", async () => {
|
||||
let db: SqliteInterface;
|
||||
let closeDb: () => void;
|
||||
let server: TestServer;
|
||||
let uid: string;
|
||||
|
||||
before(async () => {
|
||||
({ db, close: closeDb } = await createTestDb());
|
||||
server = await createTestServer(db);
|
||||
const pw = await hashPassword(PASSWORD);
|
||||
const user = await db.createUser({
|
||||
email: EMAIL,
|
||||
name: "Auth HTTP User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
uid = user.uid;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server.close();
|
||||
closeDb();
|
||||
});
|
||||
|
||||
it("POST /auth/login sets a session cookie", async () => {
|
||||
const res = await fetch(`${server.url}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const cookie = getCookie(res, "abode_session");
|
||||
assert.ok(cookie, "session cookie set");
|
||||
});
|
||||
|
||||
it("session cookie authenticates GET /auth/self", async () => {
|
||||
const login = await fetch(`${server.url}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
|
||||
});
|
||||
const cookie = getCookie(login, "abode_session");
|
||||
|
||||
const self = await fetch(`${server.url}/auth/self`, {
|
||||
headers: { Cookie: `abode_session=${cookie}` },
|
||||
});
|
||||
assert.equal(self.status, 200);
|
||||
const body = (await self.json()) as { uid: string };
|
||||
assert.equal(body.uid, uid);
|
||||
});
|
||||
|
||||
it("GET /auth/self without credentials returns 401", async () => {
|
||||
const res = await fetch(`${server.url}/auth/self`);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it("POST /auth/logout clears the cookie and invalidates the session server-side", async () => {
|
||||
const login = await fetch(`${server.url}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
|
||||
});
|
||||
const cookie = getCookie(login, "abode_session");
|
||||
|
||||
const logout = await fetch(`${server.url}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: `abode_session=${cookie}` },
|
||||
});
|
||||
assert.equal(logout.status, 204);
|
||||
assert.equal(getCookie(logout, "abode_session"), "");
|
||||
|
||||
const self = await fetch(`${server.url}/auth/self`, {
|
||||
headers: { Cookie: `abode_session=${cookie}` },
|
||||
});
|
||||
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 () => {
|
||||
const login = await fetch(`${server.url}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
|
||||
});
|
||||
const cookie = getCookie(login, "abode_session");
|
||||
|
||||
const clear = await fetch(`${server.url}/auth/clear-sessions`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: `abode_session=${cookie}` },
|
||||
});
|
||||
assert.equal(clear.status, 204);
|
||||
|
||||
const self = await fetch(`${server.url}/auth/self`, {
|
||||
headers: { Cookie: `abode_session=${cookie}` },
|
||||
});
|
||||
assert.equal(self.status, 401);
|
||||
});
|
||||
|
||||
it("Bearer apikey token authenticates protected routes", async () => {
|
||||
const [, token] = await db.createApikey({
|
||||
uid,
|
||||
name: "HTTP Test Key",
|
||||
permissions: { all: true },
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/users`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
it("invalid Bearer apikey token returns 401 invalid_apikey", async () => {
|
||||
const res = await fetch(`${server.url}/users`, {
|
||||
headers: { Authorization: `Bearer at_${"0".repeat(32)}` },
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
assert.equal(body.error, "invalid_apikey");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createTestDb } from "../../helpers/sqlite.js";
|
||||
import { createTestServer } from "../../helpers/koa.js";
|
||||
import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
|
||||
import { hashPassword } from "../../../src/util/hash.js";
|
||||
import { runUserTests } from "../../shared/users.js";
|
||||
import { runAbodeTests } from "../../shared/abodes.js";
|
||||
import { runResidentTests } from "../../shared/residents.js";
|
||||
import { runApikeyTests } from "../../shared/apikeys.js";
|
||||
|
||||
const AUTH_EMAIL = "api-auth@test.example";
|
||||
const AUTH_PASSWORD = "api-auth-password";
|
||||
|
||||
async function getApiDb() {
|
||||
const { db: sqliteDb, close: closeSqlite } = await createTestDb();
|
||||
const pw = await hashPassword(AUTH_PASSWORD);
|
||||
await sqliteDb.createUser({
|
||||
email: AUTH_EMAIL,
|
||||
name: "API Auth User",
|
||||
password: pw,
|
||||
// The shared backend contract suite exercises unrestricted user lookup,
|
||||
// which the HTTP API now reserves for global administrators.
|
||||
flags: { admin: true },
|
||||
});
|
||||
const server = await createTestServer(sqliteDb);
|
||||
const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`);
|
||||
const api = new ApiInterface(server.url, {
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
return {
|
||||
db: api,
|
||||
close: async () => {
|
||||
await server.close();
|
||||
closeSqlite();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getReadonlyApiDb() {
|
||||
const { db: sqliteDb, close: closeSqlite } = await createTestDb();
|
||||
const server = await createTestServer(sqliteDb);
|
||||
const api = new ApiInterface(server.url, { readonly: true });
|
||||
return {
|
||||
db: api,
|
||||
close: async () => {
|
||||
await server.close();
|
||||
closeSqlite();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
runUserTests("api", getApiDb, getReadonlyApiDb);
|
||||
runAbodeTests("api", getApiDb);
|
||||
runResidentTests("api", getApiDb);
|
||||
runApikeyTests("api", getApiDb);
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Readable } from "node:stream";
|
||||
import { PostgresInterface } from "../../../src/db/postgres/PostgresInterface.js";
|
||||
import type { WrappedPgClient } from "../../../src/db/postgres/pool.js";
|
||||
import type { SqlCode } from "../../../src/db/postgres/sql.js";
|
||||
import {
|
||||
EXPORT_KIND_ORDER,
|
||||
type ExportKind,
|
||||
} from "../../../src/db/types/ExportImport.js";
|
||||
|
||||
// The postgres backend has no CI database, so these tests drive the real
|
||||
// PostgresInterface.export/import code paths against an in-memory fake client.
|
||||
// They verify control flow (NDJSON shape, meta.source, filtering, note
|
||||
// skipping, counts, insert dispatch, abort -> rollback); the SQL-arg forms are
|
||||
// the same {uuid}/{text}/{jsonb}/{date} patterns the pg backend's own CRUD
|
||||
// already exercises against real Postgres.
|
||||
|
||||
interface Rows {
|
||||
users?: Record<string, unknown>[];
|
||||
abodes?: Record<string, unknown>[];
|
||||
residents?: Record<string, unknown>[];
|
||||
apikeys?: Record<string, unknown>[];
|
||||
notes?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
class FakePg implements WrappedPgClient {
|
||||
readonly = false;
|
||||
inserts: { table: string; vars: unknown[] }[] = [];
|
||||
committed = false;
|
||||
rolledBack = false;
|
||||
#rows: Rows;
|
||||
|
||||
constructor(rows: Rows = {}) {
|
||||
this.#rows = rows;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {}
|
||||
|
||||
async all<R>(stmt: SqlCode): Promise<R[]> {
|
||||
const s = stmt._sql;
|
||||
if (s.includes('FROM "users"')) return (this.#rows.users ?? []) as R[];
|
||||
if (s.includes('FROM "abodes"')) return (this.#rows.abodes ?? []) as R[];
|
||||
if (s.includes('FROM "residents"'))
|
||||
return (this.#rows.residents ?? []) as R[];
|
||||
if (s.includes('FROM "apikeys"')) return (this.#rows.apikeys ?? []) as R[];
|
||||
if (s.includes('FROM "notes"')) return (this.#rows.notes ?? []) as R[];
|
||||
return [] as R[];
|
||||
}
|
||||
|
||||
async get<R>(stmt: SqlCode): Promise<R | null> {
|
||||
const rows = await this.all<R>(stmt);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async run(stmt: SqlCode): Promise<{ changes: number }> {
|
||||
const table = stmt._sql.match(/INSERT INTO "(\w+)"/)?.[1] ?? "?";
|
||||
this.inserts.push({ table, vars: stmt._vars });
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
async multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
|
||||
try {
|
||||
const r = await fn(this);
|
||||
this.committed = true;
|
||||
return r;
|
||||
} catch (e) {
|
||||
this.rolledBack = true;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async rethrow<R>(fn: () => Promise<R>): Promise<R> {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
const iso = "2026-01-02T03:04:05.000Z";
|
||||
|
||||
function seededRows(): Rows {
|
||||
return {
|
||||
users: [
|
||||
{
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: new Date(iso),
|
||||
updated_at: new Date(iso),
|
||||
},
|
||||
],
|
||||
abodes: [
|
||||
{
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
name: "Abode One",
|
||||
created_at: new Date(iso),
|
||||
created_by: "11111111-1111-1111-1111-111111111111",
|
||||
updated_at: new Date(iso),
|
||||
updated_by: null,
|
||||
},
|
||||
{
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2",
|
||||
name: "Abode Two",
|
||||
created_at: new Date(iso),
|
||||
created_by: null,
|
||||
updated_at: new Date(iso),
|
||||
updated_by: null,
|
||||
},
|
||||
],
|
||||
residents: [
|
||||
{
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
flags: {},
|
||||
created_at: new Date(iso),
|
||||
created_by: null,
|
||||
updated_at: new Date(iso),
|
||||
updated_by: null,
|
||||
},
|
||||
],
|
||||
apikeys: [
|
||||
{
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
|
||||
name: "key one",
|
||||
permissions: {},
|
||||
created_at: new Date(iso),
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
{
|
||||
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
name: "Note one",
|
||||
content: "# Content",
|
||||
properties: { type: "note" },
|
||||
created_at: new Date(iso),
|
||||
created_by: "11111111-1111-1111-1111-111111111111",
|
||||
updated_at: new Date(iso),
|
||||
updated_by: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function streamToString(s: NodeJS.ReadableStream): Promise<string> {
|
||||
let out = "";
|
||||
for await (const chunk of s) out += chunk;
|
||||
return out;
|
||||
}
|
||||
function parseLines(ndjson: string): { kind: string; data: any }[] {
|
||||
return ndjson
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
function line(kind: string, data: unknown): string {
|
||||
return JSON.stringify({ kind, data }) + "\n";
|
||||
}
|
||||
|
||||
describe("postgres export", () => {
|
||||
it("streams meta and all supported kinds, including notes", async () => {
|
||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||
const lines = parseLines(await streamToString(db.export()));
|
||||
|
||||
const meta = lines.find((l) => l.kind === "meta");
|
||||
assert.ok(meta);
|
||||
assert.equal(meta!.data.source, "postgres");
|
||||
assert.equal(meta!.data.v, 1);
|
||||
|
||||
const kinds = new Set(lines.map((l) => l.kind));
|
||||
assert.ok(kinds.has("user"));
|
||||
assert.ok(kinds.has("abode"));
|
||||
assert.ok(kinds.has("resident"));
|
||||
assert.ok(kinds.has("apikey"));
|
||||
assert.ok(kinds.has("note"));
|
||||
});
|
||||
|
||||
it("emits record kinds grouped in FK-safe EXPORT_KIND_ORDER", async () => {
|
||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||
const lines = parseLines(await streamToString(db.export()));
|
||||
assert.equal(lines[0]?.kind, "meta", "first line is meta");
|
||||
const rank = (k: string) => EXPORT_KIND_ORDER.indexOf(k as ExportKind);
|
||||
let last = -1;
|
||||
for (const { kind } of lines.slice(1)) {
|
||||
const r = rank(kind);
|
||||
assert.notEqual(r, -1, `unexpected kind ${kind}`);
|
||||
assert.ok(r >= last, `kind ${kind} out of FK-safe order`);
|
||||
last = r;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the kinds filter", async () => {
|
||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||
const lines = parseLines(
|
||||
await streamToString(db.export({ filter: { kinds: ["abode"] } })),
|
||||
);
|
||||
const kinds = new Set(
|
||||
lines.filter((l) => l.kind !== "meta").map((l) => l.kind),
|
||||
);
|
||||
assert.deepEqual(kinds, new Set(["abode"]));
|
||||
});
|
||||
|
||||
it("applies the abodes allowlist to abode/resident records", async () => {
|
||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||
const lines = parseLines(
|
||||
await streamToString(
|
||||
db.export({
|
||||
filter: { abodes: ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const abodeAids = lines
|
||||
.filter((l) => l.kind === "abode")
|
||||
.map((l) => l.data.aid);
|
||||
assert.deepEqual(abodeAids, ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postgres import", () => {
|
||||
it("dispatches inserts per kind, including note, and commits", async () => {
|
||||
const fake = new FakePg();
|
||||
const db = new PostgresInterface(fake);
|
||||
const source = Readable.from([
|
||||
line("meta", { v: 1 }),
|
||||
line("user", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: iso,
|
||||
updated_at: iso,
|
||||
}),
|
||||
line("abode", {
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
name: "Abode One",
|
||||
created_at: iso,
|
||||
created_by: "11111111-1111-1111-1111-111111111111",
|
||||
updated_at: iso,
|
||||
updated_by: null,
|
||||
}),
|
||||
line("apikey", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
|
||||
name: "key one",
|
||||
permissions: {},
|
||||
created_at: iso,
|
||||
expires_at: null,
|
||||
}),
|
||||
line("note", {
|
||||
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
name: "Note",
|
||||
content: "x",
|
||||
properties: {},
|
||||
created_at: iso,
|
||||
created_by: null,
|
||||
updated_at: iso,
|
||||
updated_by: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await db.import(source);
|
||||
assert.deepEqual(result.counts, { user: 1, abode: 1, apikey: 1, note: 1 });
|
||||
assert.ok(fake.committed);
|
||||
assert.deepEqual(fake.inserts.map((i) => i.table).sort(), [
|
||||
"abodes",
|
||||
"apikeys",
|
||||
"notes",
|
||||
"users",
|
||||
]);
|
||||
// apikey gets a freshly-minted token (never exported)
|
||||
const apikeyInsert = fake.inserts.find((i) => i.table === "apikeys")!;
|
||||
assert.ok(
|
||||
apikeyInsert.vars.some(
|
||||
(v) => typeof v === "string" && v.startsWith("at_"),
|
||||
),
|
||||
"apikey insert carries a fresh token",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects and rolls back when the stream carries an error sentinel", async () => {
|
||||
const fake = new FakePg();
|
||||
const db = new PostgresInterface(fake);
|
||||
const source = Readable.from([
|
||||
line("meta", { v: 1 }),
|
||||
line("user", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: iso,
|
||||
updated_at: iso,
|
||||
}),
|
||||
line("error", { message: "boom" }),
|
||||
]);
|
||||
|
||||
await assert.rejects(() => db.import(source), /boom/);
|
||||
assert.ok(fake.rolledBack);
|
||||
assert.ok(!fake.committed);
|
||||
});
|
||||
|
||||
it("rejects and rolls back on signal abort", async () => {
|
||||
const fake = new FakePg();
|
||||
const db = new PostgresInterface(fake);
|
||||
const ac = new AbortController();
|
||||
const source = Readable.from(
|
||||
(async function* () {
|
||||
yield line("meta", { v: 1 });
|
||||
yield line("user", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: iso,
|
||||
updated_at: iso,
|
||||
});
|
||||
ac.abort();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
})(),
|
||||
);
|
||||
|
||||
await assert.rejects(() => db.import(source, { signal: ac.signal }));
|
||||
assert.ok(fake.rolledBack);
|
||||
assert.ok(!fake.committed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BackendDbInterface } from "../../../src/db/types/DbInterface.js";
|
||||
import { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js";
|
||||
import { createTestDb, createReadonlyTestDb } from "../../helpers/sqlite.js";
|
||||
import { runUserTests } from "../../shared/users.js";
|
||||
import { runAbodeTests } from "../../shared/abodes.js";
|
||||
import { runResidentTests } from "../../shared/residents.js";
|
||||
import { runApikeyTests } from "../../shared/apikeys.js";
|
||||
import { runSessionTests } from "../../shared/sessions.js";
|
||||
import { runAuthTests } from "../../shared/auth.js";
|
||||
|
||||
async function createExpiredApikey(
|
||||
db: BackendDbInterface,
|
||||
uid: string,
|
||||
): Promise<`at_${string}`> {
|
||||
const si = db as SqliteInterface;
|
||||
const token = `at_${"e".repeat(32)}` as `at_${string}`;
|
||||
const kid = crypto.randomUUID();
|
||||
const { sql } = si._;
|
||||
si._.db.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: uid }},
|
||||
${{ uuid: kid }},
|
||||
${{ text: token }},
|
||||
${{ text: "Expired Key" }},
|
||||
${{ jsonb: {} }},
|
||||
${{ date: new Date(Date.now() - 10000).toISOString() }}
|
||||
)
|
||||
`);
|
||||
return token;
|
||||
}
|
||||
|
||||
runUserTests("sqlite", createTestDb, createReadonlyTestDb);
|
||||
runAbodeTests("sqlite", createTestDb);
|
||||
runResidentTests("sqlite", createTestDb);
|
||||
runApikeyTests("sqlite", createTestDb);
|
||||
runSessionTests("sqlite", createTestDb);
|
||||
runAuthTests("sqlite", createTestDb, createExpiredApikey);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js";
|
||||
import { SqliteMigrator } from "../../../src/db/sqlite/SqliteMigrator.js";
|
||||
import { migrations } from "../../../src/db/sqlite/migrations/index.js";
|
||||
|
||||
describe("SqliteMigrator", () => {
|
||||
it("listAvailableMigrations returns all known migrations", () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
const available = migrator.listAvailableMigrations();
|
||||
assert.ok(Array.isArray(available));
|
||||
assert.ok(available.length >= 3, "at least 3 migrations");
|
||||
const ids = available.map((m) => m.id);
|
||||
assert.ok(ids.includes(1), "migration 1 present");
|
||||
assert.ok(ids.includes(2), "migration 2 present");
|
||||
assert.ok(ids.includes(3), "migration 3 present");
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("listAppliedMigrations returns empty array on fresh db", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.deepEqual(applied, []);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo(1) applies first migration", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await migrator.migrateTo(1);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.equal(applied.length, 1);
|
||||
assert.equal(applied[0].id, 1);
|
||||
assert.ok(applied[0].name, "migration has a name");
|
||||
assert.ok(applied[0].applied_at, "migration has applied_at");
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo(3) applies all three migrations in order", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await migrator.migrateTo(3);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.equal(applied.length, 3);
|
||||
assert.deepEqual(
|
||||
applied.map((m) => m.id),
|
||||
[1, 2, 3],
|
||||
);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo(3) twice is idempotent (nothing to do)", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await migrator.migrateTo(3);
|
||||
await migrator.migrateTo(3);
|
||||
const applied = await migrator.listAppliedMigrations();
|
||||
assert.equal(applied.length, 3);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("migrateTo with unknown id throws", async () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
await assert.rejects(
|
||||
() => migrator.migrateTo(9999),
|
||||
/No known migration with id 9999/,
|
||||
);
|
||||
db.destroy();
|
||||
});
|
||||
|
||||
it("listAvailableMigrations names match migration objects", () => {
|
||||
const db = new WrappedNodeSqliteDb(":memory:");
|
||||
const migrator = new SqliteMigrator(db);
|
||||
const available = migrator.listAvailableMigrations();
|
||||
for (const { id, name } of available) {
|
||||
const migration = migrations.find((m) => m.id === id);
|
||||
assert.ok(migration, `migration ${id} exists`);
|
||||
assert.equal(migration.name, name);
|
||||
}
|
||||
db.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
sql,
|
||||
catSql,
|
||||
joinSql,
|
||||
calcUpdates,
|
||||
unsafeSql,
|
||||
} from "../../../src/db/sqlite/sql.js";
|
||||
|
||||
describe("sql template tag", () => {
|
||||
it("produces correct sql and empty vars for plain text", () => {
|
||||
const result = sql`SELECT 1`;
|
||||
assert.equal(result._sql, "SELECT 1");
|
||||
assert.deepEqual(result._vars, []);
|
||||
});
|
||||
|
||||
it("binds text args with ?", () => {
|
||||
const result = sql`WHERE name = ${{ text: "alice" }}`;
|
||||
assert.equal(result._sql, "WHERE name = ?");
|
||||
assert.deepEqual(result._vars, ["alice"]);
|
||||
});
|
||||
|
||||
it("binds uuid args with ? and converts to Buffer", () => {
|
||||
const uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
const result = sql`WHERE uid = ${{ uuid }}`;
|
||||
assert.equal(result._sql, "WHERE uid = ?");
|
||||
assert.equal(result._vars.length, 1);
|
||||
assert.ok(result._vars[0] instanceof Buffer, "uuid is stored as Buffer");
|
||||
});
|
||||
|
||||
it("binds jsonb args with jsonb(?) wrapper", () => {
|
||||
const result = sql`SET flags = ${{ jsonb: { admin: true } }}`;
|
||||
assert.equal(result._sql, "SET flags = jsonb(?)");
|
||||
assert.deepEqual(result._vars, [JSON.stringify({ admin: true })]);
|
||||
});
|
||||
|
||||
it("binds date args with datetime(?, ...) wrapper", () => {
|
||||
const date = "2024-01-01T00:00:00.000Z";
|
||||
const result = sql`SET ts = ${{ date }}`;
|
||||
assert.ok(result._sql.startsWith("SET ts = datetime("), result._sql);
|
||||
assert.equal(result._vars.length, 1);
|
||||
});
|
||||
|
||||
it("binds int args with ?", () => {
|
||||
const result = sql`LIMIT ${{ int: 10 }}`;
|
||||
assert.equal(result._sql, "LIMIT ?");
|
||||
assert.deepEqual(result._vars, [10]);
|
||||
});
|
||||
|
||||
it("throws for non-integer int value", () => {
|
||||
assert.throws(() => sql`LIMIT ${{ int: 10.5 }}`, /Not an integer/);
|
||||
});
|
||||
|
||||
it("emits NULL for null args", () => {
|
||||
const result = sql`= ${{ null: true }}`;
|
||||
assert.equal(result._sql, "= NULL");
|
||||
assert.deepEqual(result._vars, []);
|
||||
});
|
||||
|
||||
it("splices nested SqlCode", () => {
|
||||
const inner = sql`AND x = ${{ text: "foo" }}`;
|
||||
const outer = sql`WHERE 1=1 ${inner}`;
|
||||
assert.equal(outer._sql, "WHERE 1=1 AND x = ?");
|
||||
assert.deepEqual(outer._vars, ["foo"]);
|
||||
});
|
||||
|
||||
it("handles multiple args", () => {
|
||||
const result = sql`INSERT INTO t(a,b) VALUES(${{ text: "x" }}, ${{ int: 42 }})`;
|
||||
assert.equal(result._sql, "INSERT INTO t(a,b) VALUES(?, ?)");
|
||||
assert.deepEqual(result._vars, ["x", 42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catSql", () => {
|
||||
it("concatenates sql and vars", () => {
|
||||
const a = sql`SELECT * FROM t`;
|
||||
const b = sql` WHERE x = ${{ text: "y" }}`;
|
||||
const result = catSql(a, b);
|
||||
assert.equal(result._sql, "SELECT * FROM t WHERE x = ?");
|
||||
assert.deepEqual(result._vars, ["y"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinSql", () => {
|
||||
it("joins multiple parts with separator", () => {
|
||||
const parts = [
|
||||
sql`a = ${{ text: "1" }}`,
|
||||
sql`b = ${{ text: "2" }}`,
|
||||
sql`c = ${{ text: "3" }}`,
|
||||
];
|
||||
const result = joinSql(parts, sql`, `);
|
||||
assert.equal(result._sql, "a = ?, b = ?, c = ?");
|
||||
assert.deepEqual(result._vars, ["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("returns single part unchanged (no separator)", () => {
|
||||
const result = joinSql([sql`x = ${{ int: 1 }}`], sql`, `);
|
||||
assert.equal(result._sql, "x = ?");
|
||||
assert.deepEqual(result._vars, [1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcUpdates", () => {
|
||||
it("returns only keys present in the object", () => {
|
||||
const calc = calcUpdates({
|
||||
name: (v: string) => sql`name = ${{ text: v }}`,
|
||||
email: (v: string) => sql`email = ${{ text: v }}`,
|
||||
});
|
||||
const updates = calc({ name: "alice" });
|
||||
assert.equal(updates.length, 1);
|
||||
assert.equal(updates[0]._sql, "name = ?");
|
||||
assert.deepEqual(updates[0]._vars, ["alice"]);
|
||||
});
|
||||
|
||||
it("returns all keys when all are present", () => {
|
||||
const calc = calcUpdates({
|
||||
a: (v: string) => sql`a = ${{ text: v }}`,
|
||||
b: (v: string) => sql`b = ${{ text: v }}`,
|
||||
});
|
||||
const updates = calc({ a: "x", b: "y" });
|
||||
assert.equal(updates.length, 2);
|
||||
});
|
||||
|
||||
it("returns empty array when no keys match", () => {
|
||||
const calc = calcUpdates({
|
||||
name: (v: string) => sql`name = ${{ text: v }}`,
|
||||
});
|
||||
const updates = calc({});
|
||||
assert.equal(updates.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsafeSql", () => {
|
||||
it("wraps a raw sql string with no vars", () => {
|
||||
const result = unsafeSql("CREATE TABLE t (id INTEGER)");
|
||||
assert.equal(result._sql, "CREATE TABLE t (id INTEGER)");
|
||||
assert.deepEqual(result._vars, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js";
|
||||
import { sql, unsafeSql } from "../../../src/db/sqlite/sql.js";
|
||||
import type { WrappedDb } from "../../../src/db/sqlite/impl/types.js";
|
||||
|
||||
function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
|
||||
describe(`${name}: WrappedDb`, () => {
|
||||
let db: WrappedDb;
|
||||
|
||||
before(() => {
|
||||
db = makeDb();
|
||||
db.run(
|
||||
unsafeSql(
|
||||
"CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
after(() => db.destroy());
|
||||
|
||||
it("run INSERT returns changes count", () => {
|
||||
const { changes } = db.run(
|
||||
sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`,
|
||||
);
|
||||
assert.equal(changes, 1);
|
||||
});
|
||||
|
||||
it("all SELECT returns all rows", () => {
|
||||
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: "b" }})`);
|
||||
const rows = db.all<{ val: string }>(
|
||||
unsafeSql("SELECT val FROM test ORDER BY val"),
|
||||
);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].val, "a");
|
||||
assert.equal(rows[1].val, "b");
|
||||
});
|
||||
|
||||
it("get returns single row or null", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "one" }})`);
|
||||
const row = db.get<{ val: string }>(unsafeSql("SELECT val FROM test"));
|
||||
assert.ok(row !== null);
|
||||
assert.equal(row.val, "one");
|
||||
|
||||
const none = db.get<{ val: string }>(
|
||||
sql`SELECT val FROM test WHERE val = ${{ text: "none" }}`,
|
||||
);
|
||||
assert.equal(none, null);
|
||||
});
|
||||
|
||||
it("get throws when multiple rows match", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup1" }})`);
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`);
|
||||
assert.throws(
|
||||
() => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")),
|
||||
/Multiple results/,
|
||||
);
|
||||
});
|
||||
|
||||
it("multi commits on success", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
db.multi(() => {
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "in-tx" }})`);
|
||||
});
|
||||
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].val, "in-tx");
|
||||
});
|
||||
|
||||
it("multi rolls back on error", () => {
|
||||
db.run(unsafeSql("DELETE FROM test"));
|
||||
assert.throws(() =>
|
||||
db.multi(() => {
|
||||
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`);
|
||||
throw new Error("abort!");
|
||||
}),
|
||||
);
|
||||
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
|
||||
assert.equal(rows.length, 0);
|
||||
});
|
||||
|
||||
it("rethrow passes through return value", () => {
|
||||
const result = db.rethrow(() => 42);
|
||||
assert.equal(result, 42);
|
||||
});
|
||||
|
||||
it("rethrow propagates non-SQLite errors unchanged", () => {
|
||||
const err = new Error("custom error");
|
||||
assert.throws(
|
||||
() =>
|
||||
db.rethrow(() => {
|
||||
throw err;
|
||||
}),
|
||||
(e) => e === err,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
runWrappedDbSuite("node-sqlite", () => new WrappedNodeSqliteDb(":memory:"));
|
||||
|
||||
describe("better-sqlite3 WrappedDb", async () => {
|
||||
let bs3Ctor: (new (path: string) => WrappedDb) | null = null;
|
||||
|
||||
try {
|
||||
const mod = await import("../../../src/db/sqlite/impl/better-sqlite3.js");
|
||||
bs3Ctor = mod.WrappedBetterSqlite3Db;
|
||||
} catch {
|
||||
// better-sqlite3 not available, skip
|
||||
}
|
||||
|
||||
if (bs3Ctor) {
|
||||
runWrappedDbSuite("better-sqlite3", () => new bs3Ctor!(":memory:"));
|
||||
} else {
|
||||
it("better-sqlite3 is not available - skipped", (t) => {
|
||||
t.skip("better-sqlite3 optional dependency not found");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import Koa from "koa";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { apirouter } from "../../src/webapi/apirouter.js";
|
||||
import type { BackendDbInterface } from "../../src/db/types/DbInterface.js";
|
||||
|
||||
export interface TestServer {
|
||||
url: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createTestServer(
|
||||
db: BackendDbInterface,
|
||||
): Promise<TestServer> {
|
||||
const app = new Koa();
|
||||
const router = apirouter(db);
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
const server = createServer(app.callback());
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getWrappedDb } from "../../src/db/sqlite/impl/index.js";
|
||||
import { SqliteMigrator } from "../../src/db/sqlite/SqliteMigrator.js";
|
||||
import { SqliteInterface } from "../../src/db/sqlite/SqliteInterface.js";
|
||||
import type { WrappedDb } from "../../src/db/sqlite/impl/types.js";
|
||||
|
||||
export interface TestDb {
|
||||
db: SqliteInterface;
|
||||
wrapped: WrappedDb;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function createTestDb(): Promise<TestDb> {
|
||||
const wrapped = getWrappedDb("node", ":memory:", {});
|
||||
const migrator = new SqliteMigrator(wrapped);
|
||||
await migrator.migrateTo(3);
|
||||
const db = new SqliteInterface(wrapped);
|
||||
return { db, wrapped, close: () => wrapped.destroy() };
|
||||
}
|
||||
|
||||
export async function createReadonlyTestDb(): Promise<TestDb> {
|
||||
const wrapped = getWrappedDb("node", ":memory:", { readonly: true });
|
||||
const db = new SqliteInterface(wrapped);
|
||||
return { db, wrapped, close: () => wrapped.destroy() };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { DbInterface } from "../../src/db/types/DbInterface.js";
|
||||
import {
|
||||
NotFoundAbodeError,
|
||||
InvalidAbodeError,
|
||||
} from "../../src/db/types/errors.js";
|
||||
import { hashPassword } from "../../src/util/hash.js";
|
||||
|
||||
export function runAbodeTests(
|
||||
name: string,
|
||||
getDb: () => Promise<{ db: DbInterface; close(): void }>,
|
||||
): void {
|
||||
describe(`${name}: abodes`, async () => {
|
||||
let db: DbInterface;
|
||||
let close: () => void;
|
||||
let ctxUid: string;
|
||||
|
||||
before(async () => {
|
||||
({ db, close } = await getDb());
|
||||
const pw = await hashPassword("abode-ctx");
|
||||
const user = await db.createUser({
|
||||
email: `abode-ctx-${Date.now()}@test.example`,
|
||||
name: "Abode Ctx User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
ctxUid = user.uid;
|
||||
});
|
||||
|
||||
after(() => close());
|
||||
|
||||
it("createAbode returns an Abode with expected fields", async () => {
|
||||
const abode = await db.createAbode(
|
||||
{ name: "Test Abode" },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
assert.ok(abode.aid, "has aid");
|
||||
assert.equal(abode.name, "Test Abode");
|
||||
assert.ok(abode.created_at);
|
||||
assert.ok(abode.updated_at);
|
||||
});
|
||||
|
||||
it("getAbodeById returns the created abode", async () => {
|
||||
const created = await db.createAbode(
|
||||
{ name: "ById Abode" },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
const found = await db.getAbodeById(created.aid);
|
||||
assert.equal(found.aid, created.aid);
|
||||
assert.equal(found.name, "ById Abode");
|
||||
});
|
||||
|
||||
it("getAbodeById throws NotFoundAbodeError for unknown aid", async () => {
|
||||
await assert.rejects(
|
||||
() => db.getAbodeById("00000000-0000-0000-0000-000000000000"),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("listAbodes includes the created abode", async () => {
|
||||
const created = await db.createAbode(
|
||||
{ name: `Listed Abode ${Date.now()}` },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
const abodes = await db.listAbodes();
|
||||
assert.ok(Array.isArray(abodes));
|
||||
const found = abodes.find((a) => a.aid === created.aid);
|
||||
assert.ok(found, "created abode appears in listAbodes");
|
||||
});
|
||||
|
||||
it("updateAbode updates the name", async () => {
|
||||
const created = await db.createAbode({ name: "Before" }, { uid: ctxUid });
|
||||
const updated = await db.updateAbode(
|
||||
{ aid: created.aid, name: "After" },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
assert.equal(updated.aid, created.aid);
|
||||
assert.equal(updated.name, "After");
|
||||
});
|
||||
|
||||
it("updateAbode with no fields throws InvalidAbodeError", async () => {
|
||||
const created = await db.createAbode(
|
||||
{ name: "No Update" },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
await assert.rejects(
|
||||
() => db.updateAbode({ aid: created.aid }, { uid: ctxUid }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof InvalidAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteAbodeById removes the abode", async () => {
|
||||
const created = await db.createAbode(
|
||||
{ name: "To Delete" },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
await db.deleteAbodeById(created.aid);
|
||||
await assert.rejects(
|
||||
() => db.getAbodeById(created.aid),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteAbodeById throws NotFoundAbodeError for unknown aid", async () => {
|
||||
await assert.rejects(
|
||||
() => db.deleteAbodeById("00000000-0000-0000-0000-000000000000"),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { DbInterface } from "../../src/db/types/DbInterface.js";
|
||||
import { NotFoundAbodeError } from "../../src/db/types/errors.js";
|
||||
import { hashPassword } from "../../src/util/hash.js";
|
||||
|
||||
export function runApikeyTests(
|
||||
name: string,
|
||||
getDb: () => Promise<{ db: DbInterface; close(): void }>,
|
||||
): void {
|
||||
describe(`${name}: apikeys`, async () => {
|
||||
let db: DbInterface;
|
||||
let close: () => void;
|
||||
let uid: string;
|
||||
|
||||
before(async () => {
|
||||
({ db, close } = await getDb());
|
||||
const pw = await hashPassword("apikey-password");
|
||||
const user = await db.createUser({
|
||||
email: `apikey-user-${Date.now()}@test.example`,
|
||||
name: "Apikey User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
uid = user.uid;
|
||||
});
|
||||
|
||||
after(() => close());
|
||||
|
||||
it("createApikey returns [ClientApikey, at_token]", async () => {
|
||||
const [apikey, token] = await db.createApikey({
|
||||
uid,
|
||||
name: "Test Key",
|
||||
permissions: {},
|
||||
});
|
||||
assert.ok(apikey.kid, "has kid");
|
||||
assert.equal(apikey.uid, uid);
|
||||
assert.equal(apikey.name, "Test Key");
|
||||
assert.ok(!("token" in apikey), "ClientApikey has no token field");
|
||||
assert.ok(token.startsWith("at_"), `token starts with at_: ${token}`);
|
||||
});
|
||||
|
||||
it("listApikeysByUser returns created key", async () => {
|
||||
const [created] = await db.createApikey({
|
||||
uid,
|
||||
name: "List Key",
|
||||
permissions: {},
|
||||
});
|
||||
const keys = await db.listApikeysByUser(uid);
|
||||
assert.ok(Array.isArray(keys));
|
||||
const found = keys.find((k) => k.kid === created.kid);
|
||||
assert.ok(found, "created key appears in listApikeysByUser");
|
||||
});
|
||||
|
||||
it("getApikeyById returns the key", async () => {
|
||||
const [created] = await db.createApikey({
|
||||
uid,
|
||||
name: "GetById Key",
|
||||
permissions: {},
|
||||
});
|
||||
const found = await db.getApikeyById(created.kid);
|
||||
assert.equal(found.kid, created.kid);
|
||||
assert.equal(found.name, "GetById Key");
|
||||
});
|
||||
|
||||
it("getApikeyById throws NotFoundAbodeError for unknown kid", async () => {
|
||||
await assert.rejects(
|
||||
() => db.getApikeyById("00000000-0000-0000-0000-000000000000"),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteApikeyById removes the key", async () => {
|
||||
const [created] = await db.createApikey({
|
||||
uid,
|
||||
name: "Delete Key",
|
||||
permissions: {},
|
||||
});
|
||||
await db.deleteApikeyById(created.kid);
|
||||
await assert.rejects(
|
||||
() => db.getApikeyById(created.kid),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteApikeyById throws NotFoundAbodeError for unknown kid", async () => {
|
||||
await assert.rejects(
|
||||
() => db.deleteApikeyById("00000000-0000-0000-0000-000000000000"),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { BackendDbInterface } from "../../src/db/types/DbInterface.js";
|
||||
import {
|
||||
NotFoundAbodeError,
|
||||
NotAuthorizedAbodeError,
|
||||
ConflictAbodeError,
|
||||
} from "../../src/db/types/errors.js";
|
||||
import { hashPassword } from "../../src/util/hash.js";
|
||||
|
||||
export function runAuthTests(
|
||||
name: string,
|
||||
getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
|
||||
createExpiredApikey?: (
|
||||
db: BackendDbInterface,
|
||||
uid: string,
|
||||
) => Promise<`at_${string}`>,
|
||||
): void {
|
||||
describe(`${name}: auth`, async () => {
|
||||
let db: BackendDbInterface;
|
||||
let close: () => void;
|
||||
let email: string;
|
||||
const password = "auth-test-password";
|
||||
|
||||
before(async () => {
|
||||
({ db, close } = await getDb());
|
||||
email = `auth-user-${Date.now()}@test.example`;
|
||||
const pw = await hashPassword(password);
|
||||
await db.createUser({
|
||||
email,
|
||||
name: "Auth User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
});
|
||||
|
||||
after(() => close());
|
||||
|
||||
it("getUserByLogin returns user on correct credentials", async () => {
|
||||
const user = await db.getUserByLogin({ email, password });
|
||||
assert.equal(user.email, email);
|
||||
assert.ok(!("password" in user));
|
||||
});
|
||||
|
||||
it("getUserByLogin throws NotAuthorizedAbodeError for wrong password", async () => {
|
||||
await assert.rejects(
|
||||
() => db.getUserByLogin({ email, password: "wrong-password" }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotAuthorizedAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("getUserByLogin throws NotFoundAbodeError for unknown email", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
db.getUserByLogin({
|
||||
email: "nobody@nowhere.example",
|
||||
password: "any",
|
||||
}),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("getUserByLogin throws ConflictAbodeError for #unset password", async () => {
|
||||
const unsetEmail = `unset-${Date.now()}@test.example`;
|
||||
await db.createUser({
|
||||
email: unsetEmail,
|
||||
name: "Unset User",
|
||||
password: "#unset",
|
||||
flags: {},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => db.getUserByLogin({ email: unsetEmail, password: "any" }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof ConflictAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`${name}: auth apikey`, async () => {
|
||||
let db: BackendDbInterface;
|
||||
let close: () => void;
|
||||
let uid: string;
|
||||
|
||||
before(async () => {
|
||||
({ db, close } = await getDb());
|
||||
const pw = await hashPassword("apikey-auth-password");
|
||||
const user = await db.createUser({
|
||||
email: `apikey-auth-${Date.now()}@test.example`,
|
||||
name: "Apikey Auth User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
uid = user.uid;
|
||||
});
|
||||
|
||||
after(() => close());
|
||||
|
||||
it("getUserByApikey returns [user, apikey] for valid token", async () => {
|
||||
const [, token] = await db.createApikey({
|
||||
uid,
|
||||
name: "Auth Key",
|
||||
permissions: {},
|
||||
});
|
||||
const [user, apikey] = await db.getUserByApikey(token);
|
||||
assert.equal(user.uid, uid);
|
||||
assert.ok(apikey.kid);
|
||||
assert.equal(apikey.uid, uid);
|
||||
});
|
||||
|
||||
it("getUserByApikey throws NotAuthorizedAbodeError for expired key", async (t) => {
|
||||
if (!createExpiredApikey) {
|
||||
t.skip("createExpiredApikey helper not provided for this backend");
|
||||
return;
|
||||
}
|
||||
const token = await createExpiredApikey(db, uid);
|
||||
await assert.rejects(
|
||||
() => db.getUserByApikey(token),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotAuthorizedAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("getUserByApikey throws NotFoundAbodeError for unknown token", async () => {
|
||||
const fakeToken = `at_${"0".repeat(32)}` as `at_${string}`;
|
||||
await assert.rejects(
|
||||
() => db.getUserByApikey(fakeToken),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { DbInterface } from "../../src/db/types/DbInterface.js";
|
||||
import {
|
||||
NotFoundAbodeError,
|
||||
InvalidAbodeError,
|
||||
} from "../../src/db/types/errors.js";
|
||||
import { hashPassword } from "../../src/util/hash.js";
|
||||
|
||||
export function runResidentTests(
|
||||
name: string,
|
||||
getDb: () => Promise<{ db: DbInterface; close(): void }>,
|
||||
): void {
|
||||
describe(`${name}: residents`, async () => {
|
||||
let db: DbInterface;
|
||||
let close: () => void;
|
||||
let uid: string;
|
||||
let aid: string;
|
||||
let ctxUid: string;
|
||||
|
||||
before(async () => {
|
||||
({ db, close } = await getDb());
|
||||
const pw = await hashPassword("resident-pw");
|
||||
// ctx user (creator of abode)
|
||||
const ctx = await db.createUser({
|
||||
email: `res-ctx-${Date.now()}@test.example`,
|
||||
name: "Resident Ctx",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
ctxUid = ctx.uid;
|
||||
// the resident user
|
||||
const resUser = await db.createUser({
|
||||
email: `resident-${Date.now()}@test.example`,
|
||||
name: "Resident User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
uid = resUser.uid;
|
||||
const abode = await db.createAbode(
|
||||
{ name: `Resident Abode ${Date.now()}` },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
aid = abode.aid;
|
||||
await db.createResident({ uid, aid, flags: {} }, { uid: ctxUid });
|
||||
});
|
||||
|
||||
after(() => close());
|
||||
|
||||
it("getResidentById returns the created resident", async () => {
|
||||
const found = await db.getResidentById(uid, aid);
|
||||
assert.equal(found.uid, uid);
|
||||
assert.equal(found.aid, aid);
|
||||
assert.ok(found.created_at);
|
||||
});
|
||||
|
||||
it("getResidentById throws NotFoundAbodeError for unknown pair", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
db.getResidentById(
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("listResidents includes the created resident", async () => {
|
||||
const residents = await db.listResidents();
|
||||
assert.ok(Array.isArray(residents));
|
||||
const found = residents.find((r) => r.uid === uid && r.aid === aid);
|
||||
assert.ok(found, "created resident appears in listResidents");
|
||||
});
|
||||
|
||||
it("listResidentsByUserId filters by uid", async () => {
|
||||
const results = await db.listResidentsByUserId(uid);
|
||||
assert.ok(results.every((r) => r.uid === uid));
|
||||
assert.ok(results.some((r) => r.aid === aid));
|
||||
});
|
||||
|
||||
it("listResidentsByAbodeId filters by aid", async () => {
|
||||
const results = await db.listResidentsByAbodeId(aid);
|
||||
assert.ok(results.every((r) => r.aid === aid));
|
||||
assert.ok(results.some((r) => r.uid === uid));
|
||||
});
|
||||
|
||||
it("listUsersByAbodeId returns users in the abode", async () => {
|
||||
const users = await db.listUsersByAbodeId(aid);
|
||||
assert.ok(Array.isArray(users));
|
||||
const found = users.find((u) => u.uid === uid);
|
||||
assert.ok(found, "resident user appears in listUsersByAbodeId");
|
||||
});
|
||||
|
||||
it("listAbodesByUserId returns abodes for user", async () => {
|
||||
const abodes = await db.listAbodesByUserId(uid);
|
||||
assert.ok(Array.isArray(abodes));
|
||||
const found = abodes.find((a) => a.aid === aid);
|
||||
assert.ok(found, "abode appears in listAbodesByUserId");
|
||||
});
|
||||
|
||||
it("updateResident updates flags", async () => {
|
||||
const updated = await db.updateResident(
|
||||
{ uid, aid, flags: { admin: true } },
|
||||
{ uid: ctxUid },
|
||||
);
|
||||
assert.equal(updated.uid, uid);
|
||||
assert.deepEqual(updated.flags, { admin: true });
|
||||
});
|
||||
|
||||
it("updateResident with no fields throws InvalidAbodeError", async () => {
|
||||
await assert.rejects(
|
||||
() => db.updateResident({ uid, aid }, { uid: ctxUid }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof InvalidAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteResidentById removes the resident then throws on re-fetch", async () => {
|
||||
const pw = await hashPassword("del-res-pw");
|
||||
const user2 = await db.createUser({
|
||||
email: `del-res-${Date.now()}@test.example`,
|
||||
name: "Del Res User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
const abode2 = await db.createAbode(
|
||||
{ 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 assert.rejects(
|
||||
() => db.getResidentById(user2.uid, abode2.aid),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteResidentById throws NotFoundAbodeError for unknown pair", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
db.deleteResidentById(
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
"00000000-0000-0000-0000-000000000003",
|
||||
),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { BackendDbInterface } from "../../src/db/types/DbInterface.js";
|
||||
import { NotFoundAbodeError } from "../../src/db/types/errors.js";
|
||||
import { hashPassword } from "../../src/util/hash.js";
|
||||
|
||||
export function runSessionTests(
|
||||
name: string,
|
||||
getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
|
||||
): void {
|
||||
describe(`${name}: sessions`, async () => {
|
||||
let db: BackendDbInterface;
|
||||
let close: () => void;
|
||||
let uid: string;
|
||||
|
||||
before(async () => {
|
||||
({ db, close } = await getDb());
|
||||
const pw = await hashPassword("session-password");
|
||||
const user = await db.createUser({
|
||||
email: `session-user-${Date.now()}@test.example`,
|
||||
name: "Session User",
|
||||
password: pw,
|
||||
flags: {},
|
||||
});
|
||||
uid = user.uid;
|
||||
});
|
||||
|
||||
after(() => close());
|
||||
|
||||
it("createSession returns an as_ token", async () => {
|
||||
const token = await db.createSession(uid);
|
||||
assert.ok(token.startsWith("as_"), `token starts with as_: ${token}`);
|
||||
assert.equal(token.length, 35, "as_ + 32 hex chars");
|
||||
});
|
||||
|
||||
it("getUserBySession returns the correct user", async () => {
|
||||
const token = await db.createSession(uid);
|
||||
const user = await db.getUserBySession(token);
|
||||
assert.equal(user.uid, uid);
|
||||
});
|
||||
|
||||
it("getUserBySession throws NotFoundAbodeError for unknown token", async () => {
|
||||
const fakeToken = `as_${"0".repeat(32)}` as `as_${string}`;
|
||||
await assert.rejects(
|
||||
() => db.getUserBySession(fakeToken),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteSessionsByUser invalidates all sessions for user", async () => {
|
||||
const token = await db.createSession(uid);
|
||||
await db.deleteSessionsByUser(uid);
|
||||
await assert.rejects(
|
||||
() => db.getUserBySession(token),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { DbInterface } from "../../src/db/types/DbInterface.js";
|
||||
import {
|
||||
NotFoundAbodeError,
|
||||
InvalidAbodeError,
|
||||
ReadonlyAbodeError,
|
||||
} from "../../src/db/types/errors.js";
|
||||
import { hashPassword } from "../../src/util/hash.js";
|
||||
|
||||
export function runUserTests(
|
||||
name: string,
|
||||
getDb: () => Promise<{ db: DbInterface; close(): void }>,
|
||||
getReadonlyDb?: () => Promise<{ db: DbInterface; close(): void }>,
|
||||
): void {
|
||||
describe(`${name}: users`, async () => {
|
||||
let db: DbInterface;
|
||||
let close: () => void;
|
||||
let hashedPw: Awaited<ReturnType<typeof hashPassword>>;
|
||||
|
||||
before(async () => {
|
||||
({ db, close } = await getDb());
|
||||
hashedPw = await hashPassword("test-password");
|
||||
});
|
||||
|
||||
after(() => close());
|
||||
|
||||
it("createUser returns a ClientUser without password", async () => {
|
||||
const user = await db.createUser({
|
||||
email: `user-create-${Date.now()}@test.example`,
|
||||
name: "Test User",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
});
|
||||
assert.ok(user.uid, "has uid");
|
||||
assert.equal(user.name, "Test User");
|
||||
assert.ok(!("password" in user), "no password field");
|
||||
assert.ok(user.created_at);
|
||||
assert.ok(user.updated_at);
|
||||
});
|
||||
|
||||
it("getUserById returns the created user", async () => {
|
||||
const created = await db.createUser({
|
||||
email: `user-byid-${Date.now()}@test.example`,
|
||||
name: "ById User",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
});
|
||||
const found = await db.getUserById(created.uid);
|
||||
assert.equal(found.uid, created.uid);
|
||||
assert.equal(found.name, "ById User");
|
||||
});
|
||||
|
||||
it("getUserById throws NotFoundAbodeError for unknown uid", async () => {
|
||||
await assert.rejects(
|
||||
() => db.getUserById("00000000-0000-0000-0000-000000000000"),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("getUserByEmail returns the created user", async () => {
|
||||
const email = `user-byemail-${Date.now()}@test.example`;
|
||||
await db.createUser({
|
||||
email,
|
||||
name: "ByEmail User",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
});
|
||||
const found = await db.getUserByEmail(email);
|
||||
assert.ok("email" in found, "result includes email");
|
||||
assert.equal(found.email, email);
|
||||
});
|
||||
|
||||
it("getUserByEmail throws NotFoundAbodeError for unknown email", async () => {
|
||||
await assert.rejects(
|
||||
() => db.getUserByEmail("nobody@nowhere.example"),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("listUsers includes the created user", async () => {
|
||||
const email = `user-list-${Date.now()}@test.example`;
|
||||
const created = await db.createUser({
|
||||
email,
|
||||
name: "Listed User",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
});
|
||||
const users = await db.listUsers();
|
||||
assert.ok(Array.isArray(users));
|
||||
const found = users.find((u) => u.uid === created.uid);
|
||||
assert.ok(found, "created user appears in listUsers");
|
||||
});
|
||||
|
||||
it("updateUser updates the name", async () => {
|
||||
const created = await db.createUser({
|
||||
email: `user-update-${Date.now()}@test.example`,
|
||||
name: "Before Update",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
});
|
||||
const updated = await db.updateUser({
|
||||
uid: created.uid,
|
||||
name: "After Update",
|
||||
});
|
||||
assert.equal(updated.uid, created.uid);
|
||||
assert.equal(updated.name, "After Update");
|
||||
});
|
||||
|
||||
it("updateUser with no fields throws InvalidAbodeError", async () => {
|
||||
const created = await db.createUser({
|
||||
email: `user-noupdate-${Date.now()}@test.example`,
|
||||
name: "No Update",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => db.updateUser({ uid: created.uid }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof InvalidAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteUserById removes the user", async () => {
|
||||
const created = await db.createUser({
|
||||
email: `user-delete-${Date.now()}@test.example`,
|
||||
name: "To Delete",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
});
|
||||
await db.deleteUserById(created.uid);
|
||||
await assert.rejects(
|
||||
() => db.getUserById(created.uid),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteUserById throws NotFoundAbodeError for unknown uid", async () => {
|
||||
await assert.rejects(
|
||||
() => db.deleteUserById("00000000-0000-0000-0000-000000000000"),
|
||||
(err) => {
|
||||
assert.ok(err instanceof NotFoundAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`${name}: users readonly`, async () => {
|
||||
let db: DbInterface;
|
||||
let close: () => void;
|
||||
let hashedPw: Awaited<ReturnType<typeof hashPassword>>;
|
||||
|
||||
before(async () => {
|
||||
if (getReadonlyDb) ({ db, close } = await getReadonlyDb());
|
||||
hashedPw = await hashPassword("test-password");
|
||||
});
|
||||
|
||||
after(() => close?.());
|
||||
|
||||
it("createUser on readonly db throws ReadonlyAbodeError", async (t) => {
|
||||
if (!getReadonlyDb) {
|
||||
t.skip("getReadonlyDb helper not provided for this backend");
|
||||
return;
|
||||
}
|
||||
assert.ok(db.readonly, "test db is readonly");
|
||||
await assert.rejects(
|
||||
() =>
|
||||
db.createUser({
|
||||
email: "readonly@test.example",
|
||||
name: "Readonly",
|
||||
password: hashedPw,
|
||||
flags: {},
|
||||
}),
|
||||
(err) => {
|
||||
assert.ok(err instanceof ReadonlyAbodeError);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user