feat: initial commit
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
||||
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
||||
import type { DbInterface } from "../types/DbInterface.js";
|
||||
import {
|
||||
ConflictAbodeError,
|
||||
InvalidAbodeError,
|
||||
NotAuthorizedAbodeError,
|
||||
NotFoundAbodeError,
|
||||
ReadonlyAbodeError,
|
||||
} from "../types/errors.js";
|
||||
import type {
|
||||
CreateNote,
|
||||
Note,
|
||||
PartialNote,
|
||||
UpdateNote,
|
||||
} from "../types/Note.js";
|
||||
import type {
|
||||
CreateResident,
|
||||
Resident,
|
||||
updateResident,
|
||||
} from "../types/Resident.js";
|
||||
import type {
|
||||
PartialUser,
|
||||
ClientUser,
|
||||
CreateUser,
|
||||
UpdateUser,
|
||||
} from "../types/User.js";
|
||||
|
||||
export class ApiInterface implements DbInterface {
|
||||
#root: string;
|
||||
#headers: Record<string, string>;
|
||||
#readonly: boolean;
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
{
|
||||
headers = {},
|
||||
readonly = false,
|
||||
}: {
|
||||
headers?: Record<string, string>;
|
||||
readonly?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
if (root.endsWith("/")) root = root.slice(0, -1);
|
||||
this.#root = root;
|
||||
this.#headers = headers;
|
||||
this.#readonly = readonly;
|
||||
}
|
||||
|
||||
#url(route: string, params?: Record<string, string>): string {
|
||||
if (params) {
|
||||
const remaining = new Map(Object.entries(params));
|
||||
route = route
|
||||
.split("/")
|
||||
.map((part) => {
|
||||
if (part.startsWith(":")) {
|
||||
const value = remaining.get(part.slice(1));
|
||||
remaining.delete(part);
|
||||
if (value === undefined)
|
||||
throw new Error(`Missing ${part} in params`);
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
return part;
|
||||
})
|
||||
.join("/");
|
||||
if (remaining.size) {
|
||||
const sp = new URLSearchParams([...remaining.entries()]);
|
||||
route += "?" + sp.toString();
|
||||
}
|
||||
}
|
||||
return this.#root + route;
|
||||
}
|
||||
|
||||
async #call<T>(
|
||||
method: string,
|
||||
route: string,
|
||||
{
|
||||
params,
|
||||
body,
|
||||
headers,
|
||||
}: {
|
||||
params?: Record<string, string>;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const resolvedHeaders = { ...this.#headers, ...headers };
|
||||
if (body !== undefined) {
|
||||
body = JSON.stringify(body);
|
||||
resolvedHeaders["Content-Type"] = "application/json";
|
||||
resolvedHeaders["Content-Length"] = "" + (body as string).length;
|
||||
}
|
||||
const url = this.#url(route, params);
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: resolvedHeaders,
|
||||
body: body as string,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
switch (res.status) {
|
||||
case 400:
|
||||
throw new InvalidAbodeError();
|
||||
case 401:
|
||||
throw new NotAuthorizedAbodeError();
|
||||
case 403:
|
||||
throw new ReadonlyAbodeError();
|
||||
case 404:
|
||||
throw new NotFoundAbodeError();
|
||||
case 409:
|
||||
throw new ConflictAbodeError();
|
||||
default:
|
||||
throw new Error(`${res.status} ${res.statusText} ${text}`);
|
||||
}
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
#checkReadonly(): void {
|
||||
if (this.#readonly) throw new ReadonlyAbodeError();
|
||||
}
|
||||
|
||||
get _() {
|
||||
return {
|
||||
root: this.#root,
|
||||
headers: { ...this.#headers },
|
||||
url: this.#url.bind(this),
|
||||
call: this.#call.bind(this),
|
||||
self: () => this.#call<ClientUser>("GET", "/auth/self"),
|
||||
};
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this.#readonly;
|
||||
}
|
||||
get backend(): false {
|
||||
return false;
|
||||
}
|
||||
get name(): "api" {
|
||||
return "api";
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
async listUsers(): Promise<(PartialUser | ClientUser)[]> {
|
||||
return this.#call("GET", "/users");
|
||||
}
|
||||
async getUserById(uid: string): Promise<PartialUser | ClientUser> {
|
||||
return this.#call("GET", "/users/:uid", { params: { uid } });
|
||||
}
|
||||
async deleteUserById(uid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
await this.#call("DELETE", "/users/:uid", { params: { uid } });
|
||||
}
|
||||
async createUser(user: CreateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("POST", "/users", { body: user });
|
||||
}
|
||||
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("PATCH", "/users/:uid", {
|
||||
params: { uid: user.uid },
|
||||
body: user,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<PartialUser | ClientUser> {
|
||||
return this.#call("GET", "/users/by-email", { params: { email } });
|
||||
}
|
||||
|
||||
async listAbodes(): Promise<Abode[]> {
|
||||
return this.#call("GET", "/abodes");
|
||||
}
|
||||
async getAbodeById(aid: string): Promise<Abode> {
|
||||
return this.#call("GET", "/abodes/:aid", { params: { aid } });
|
||||
}
|
||||
async deleteAbodeById(aid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
await this.#call("DELETE", "/abodes/:aid", { params: { aid } });
|
||||
}
|
||||
async createAbode(abode: CreateAbode): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("POST", "/abodes", { body: abode });
|
||||
}
|
||||
async updateAbode(abode: UpdateAbode): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("PATCH", "/abodes/:aid", {
|
||||
params: { aid: abode.aid },
|
||||
body: abode,
|
||||
});
|
||||
}
|
||||
|
||||
async listResidents(): Promise<Resident[]> {
|
||||
return this.#call("GET", "/residents");
|
||||
}
|
||||
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
||||
return this.#call("GET", "/residents/:uid/:aid", { params: { uid, aid } });
|
||||
}
|
||||
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
await this.#call("DELETE", "/residents/:uid/:aid", {
|
||||
params: { uid, aid },
|
||||
});
|
||||
}
|
||||
async createResident(resident: CreateResident): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("POST", "/residents", { body: resident });
|
||||
}
|
||||
async updateResident(resident: updateResident): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("PATCH", "/residents/:uid/:aid", {
|
||||
params: { uid: resident.uid, aid: resident.aid },
|
||||
body: resident,
|
||||
});
|
||||
}
|
||||
|
||||
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
||||
return this.#call("GET", "/users/:uid/residents", { params: { uid } });
|
||||
}
|
||||
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
||||
return this.#call("GET", "/abodes/:aid/residents", { params: { aid } });
|
||||
}
|
||||
|
||||
async listUsersByAbodeId(aid: string): Promise<(PartialUser | ClientUser)[]> {
|
||||
return this.#call("GET", "/abodes/:aid/users", { params: { aid } });
|
||||
}
|
||||
async listAbodesByUserId(uid: string): Promise<Abode[]> {
|
||||
return this.#call("GET", "/users/:uid/abodes", { params: { uid } });
|
||||
}
|
||||
|
||||
async deleteSessionsByUser(uid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
await this.#call("POST", "/users/:uid/auth/clear-sessions", {
|
||||
params: { uid },
|
||||
});
|
||||
}
|
||||
|
||||
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
||||
return this.#call("GET", "/users/:uid/apikeys", { params: { uid } });
|
||||
}
|
||||
async getApikeyById(kid: string): Promise<ClientApikey> {
|
||||
return this.#call("GET", "/apikeys/:kid", {
|
||||
params: { kid },
|
||||
});
|
||||
}
|
||||
async createApikey(
|
||||
apikey: CreateApikey
|
||||
): Promise<[ClientApikey, `at_${string}`]> {
|
||||
this.#checkReadonly();
|
||||
const { apikey: key, token } = await this.#call<{
|
||||
apikey: ClientApikey;
|
||||
token: `at_${string}`;
|
||||
}>("POST", "/users/:uid/apikeys", {
|
||||
params: { uid: apikey.uid },
|
||||
body: apikey,
|
||||
});
|
||||
return [key, token];
|
||||
}
|
||||
async deleteApikeyById(kid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
await this.#call("DELETE", "/apikeys/:kid", {
|
||||
params: { kid },
|
||||
});
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async createNote(note: CreateNote): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async updateNote(note: UpdateNote): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GetDbDynamic } from "../types/GetDb.js";
|
||||
import { apiProtocols } from "./url.js";
|
||||
|
||||
const getApi = () =>
|
||||
import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then(
|
||||
(x) => x.default
|
||||
);
|
||||
|
||||
const getApiDynamic: GetDbDynamic = {
|
||||
name: "api",
|
||||
protocols: apiProtocols,
|
||||
getSource: getApi,
|
||||
};
|
||||
export default getApiDynamic;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { GetDbStatic } from "../types/GetDb.js";
|
||||
import { ApiInterface } from "./ApiInterface.js";
|
||||
import { apiProtocols, isApiUrl, parseApiUrl } from "./url.js";
|
||||
|
||||
const getApiStatic: GetDbStatic = {
|
||||
name: "api",
|
||||
protocols: apiProtocols,
|
||||
checkUrl: isApiUrl,
|
||||
getDbInterface: async (url) => {
|
||||
const [root, { headers, readonly }] = parseApiUrl(url);
|
||||
const db = new ApiInterface(root, { headers, readonly });
|
||||
await db._.self();
|
||||
return db;
|
||||
},
|
||||
getMigrator: async () => {
|
||||
throw new Error("No migrator for api");
|
||||
},
|
||||
};
|
||||
export default getApiStatic;
|
||||
@@ -0,0 +1,36 @@
|
||||
export const apiProtocols = ["abode+https:", "abode+http:", "https:", "http:"];
|
||||
|
||||
export function isApiUrl(url: string) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return apiProtocols.includes(urlObj.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseApiUrl(url: string) {
|
||||
const urlObj = new URL(url);
|
||||
if (!apiProtocols.includes(urlObj.protocol))
|
||||
throw new Error("Not an {abode+,}http{s,}: protocol");
|
||||
// can't replace just the protocol, apparently
|
||||
urlObj.href = urlObj.href.replace(/^abode\+/, "");
|
||||
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
|
||||
const headers = Object.fromEntries(
|
||||
[...urlObj.searchParams.entries()].filter(([param]) => param !== "readonly")
|
||||
);
|
||||
if (urlObj.username) {
|
||||
headers["Authorization"] =
|
||||
"Basic " +
|
||||
btoa(
|
||||
[
|
||||
decodeURIComponent(urlObj.username),
|
||||
decodeURIComponent(urlObj.password),
|
||||
].join(":")
|
||||
);
|
||||
urlObj.username = "";
|
||||
urlObj.password = "";
|
||||
}
|
||||
urlObj.search = "";
|
||||
return [urlObj.href, { headers, readonly }] as const;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import getApiDynamic from "./api/getdb.dyn.js";
|
||||
import getSqliteDynamic from "./sqlite/getdb.dyn.js";
|
||||
import type { GetDbDynamic, GetDbStatic } from "./types/GetDb.js";
|
||||
|
||||
const dynamicSources: GetDbDynamic[] = [getSqliteDynamic, getApiDynamic];
|
||||
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||
const urlObj = new URL(url);
|
||||
const dbSources: GetDbStatic[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const source of dynamicSources) {
|
||||
if (source.protocols.includes(urlObj.protocol)) {
|
||||
promises.push(
|
||||
source
|
||||
.getSource(url)
|
||||
.catch(() => null)
|
||||
.then((dbSource) => {
|
||||
if (dbSource) dbSources.push(dbSource);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
return dbSources;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { GetDbStatic } from "./types/GetDb.js";
|
||||
|
||||
let rawGetDbSources: typeof getDbSources | undefined = undefined;
|
||||
const getGetDbSources = () =>
|
||||
import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then(
|
||||
(x) => x.getDbSources
|
||||
);
|
||||
|
||||
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||
if (!rawGetDbSources) rawGetDbSources = await getGetDbSources();
|
||||
return rawGetDbSources(url);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import getApiStatic from "./api/getdb.static.js";
|
||||
import getSqliteStatic from "./sqlite/getdb.static.js";
|
||||
import type { GetDbStatic } from "./types/GetDb.js";
|
||||
|
||||
const dbSources: GetDbStatic[] = [getSqliteStatic, getApiStatic];
|
||||
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||
void url;
|
||||
return dbSources;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./dbSources.static.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getDbSources } from "./dbSources.js";
|
||||
import type { DbInterface } from "./types/DbInterface.js";
|
||||
import type { Migrator } from "./types/Migrator.js";
|
||||
|
||||
export async function getDbInterface(url: string): Promise<DbInterface> {
|
||||
for (const source of await getDbSources(url)) {
|
||||
if (source.checkUrl(url)) return source.getDbInterface(url);
|
||||
}
|
||||
throw new Error(`No source found for url ${url}`);
|
||||
}
|
||||
export async function getMigrator(url: string): Promise<Migrator> {
|
||||
for (const source of await getDbSources(url)) {
|
||||
if (source.checkUrl(url)) return source.getMigrator(url);
|
||||
}
|
||||
throw new Error(`No source found for url ${url}`);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import type { BackendDbInterface } from "../types/DbInterface.js";
|
||||
import {
|
||||
isValidUserPassword,
|
||||
type ClientUser,
|
||||
type CreateUser,
|
||||
type LoginUser,
|
||||
type UpdateUser,
|
||||
type UserFlags,
|
||||
} from "../types/User.js";
|
||||
import { sqliteToClientUser, sqliteToDate, sqliteToUuid } from "./cast.js";
|
||||
import {
|
||||
ConflictAbodeError,
|
||||
InvalidAbodeError,
|
||||
NotAuthorizedAbodeError,
|
||||
NotFoundAbodeError,
|
||||
ReadonlyAbodeError,
|
||||
} from "../types/errors.js";
|
||||
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
||||
import type {
|
||||
CreateResident,
|
||||
Resident,
|
||||
ResidentFlags,
|
||||
updateResident,
|
||||
} from "../types/Resident.js";
|
||||
import { validatePassword } from "../../util/hash.js";
|
||||
import { createApikeyToken, createSessionToken } from "../../util/token.js";
|
||||
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
||||
import { calcUpdates, catSql, joinSql, sql } from "./sql.js";
|
||||
import {
|
||||
selectAbode,
|
||||
selectAbodes,
|
||||
selectClientApikey,
|
||||
selectClientApikeys,
|
||||
selectClientUser,
|
||||
selectClientUsers,
|
||||
selectResident,
|
||||
selectResidents,
|
||||
} from "./query.js";
|
||||
import type {
|
||||
CreateNote,
|
||||
Note,
|
||||
PartialNote,
|
||||
UpdateNote,
|
||||
} from "../types/Note.js";
|
||||
import type { WrappedDb } from "./impl/types.js";
|
||||
|
||||
export class SqliteInterface implements BackendDbInterface {
|
||||
#db: WrappedDb;
|
||||
|
||||
constructor(db: WrappedDb) {
|
||||
this.#db = db;
|
||||
}
|
||||
|
||||
#checkReadonly(): void {
|
||||
if (this.#db.readonly) throw new ReadonlyAbodeError();
|
||||
}
|
||||
|
||||
get _() {
|
||||
return {
|
||||
sql,
|
||||
catSql,
|
||||
joinSql,
|
||||
db: this.#db,
|
||||
};
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this.#db.readonly;
|
||||
}
|
||||
get backend(): true {
|
||||
return true;
|
||||
}
|
||||
get name(): "sqlite" {
|
||||
return "sqlite";
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#db.destroy();
|
||||
}
|
||||
|
||||
async listUsers(): Promise<ClientUser[]> {
|
||||
return selectClientUsers(this.#db);
|
||||
}
|
||||
#getUserById(uid: string): ClientUser {
|
||||
const user = selectClientUser(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||
if (!user) throw new NotFoundAbodeError();
|
||||
return user;
|
||||
}
|
||||
async getUserById(id: string): Promise<ClientUser> {
|
||||
return this.#getUserById(id);
|
||||
}
|
||||
async getUserByEmail(email: string): Promise<ClientUser> {
|
||||
const user = selectClientUser(this.#db, sql`"email" = ${{ text: email }}`);
|
||||
if (!user) throw new NotFoundAbodeError();
|
||||
return user;
|
||||
}
|
||||
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
|
||||
const rawUser = this.#db.get<{
|
||||
uid: Buffer;
|
||||
email: string;
|
||||
name: string;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
password: string;
|
||||
}>(
|
||||
sql`
|
||||
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
|
||||
FROM "users"
|
||||
WHERE "email" = ${{ text: email }}
|
||||
`
|
||||
);
|
||||
if (!rawUser) throw new NotFoundAbodeError();
|
||||
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
|
||||
if (!(await validatePassword(password, rawUser.password)))
|
||||
throw new NotAuthorizedAbodeError();
|
||||
return sqliteToClientUser(rawUser);
|
||||
}
|
||||
async deleteUserById(id: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "users"
|
||||
WHERE "uid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createUser(user: CreateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
|
||||
const uid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
this.#db.run(
|
||||
sql`
|
||||
INSERT INTO "users"("uid", "email", "name", "password", "flags")
|
||||
VALUES(
|
||||
${{ uuid: uid }},
|
||||
${{ text: user.email }},
|
||||
${{ text: user.name }},
|
||||
${{ text: user.password }},${{ jsonb: user.flags }})
|
||||
`
|
||||
);
|
||||
return this.#getUserById(uid);
|
||||
})
|
||||
);
|
||||
}
|
||||
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||
this.#checkReadonly();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
const updates = calcUpdates({
|
||||
email: (value: string) => sql`"email" = ${{ text: value }}`,
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
password: (value: string) => {
|
||||
if (!isValidUserPassword(value)) throw new InvalidAbodeError();
|
||||
if (value.startsWith("#")) {
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "apikeys"
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`);
|
||||
}
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`);
|
||||
return sql`"password" = ${{ text: value }}`;
|
||||
},
|
||||
flags: (value: UserFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||
})(user);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
UPDATE "users"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "uid" = ${{ uuid: user.uid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getUserById(user.uid);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async listAbodes(): Promise<Abode[]> {
|
||||
return selectAbodes(this.#db);
|
||||
}
|
||||
#getAbodeById(aid: string): Abode {
|
||||
const abode = selectAbode(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||
if (!abode) throw new NotFoundAbodeError();
|
||||
return abode;
|
||||
}
|
||||
async getAbodeById(id: string): Promise<Abode> {
|
||||
return this.#getAbodeById(id);
|
||||
}
|
||||
async deleteAbodeById(id: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "abodes"
|
||||
WHERE "aid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
const aid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
this.#db.run(
|
||||
sql`
|
||||
INSERT INTO "abodes"("aid", "name", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: aid }},
|
||||
${{ text: abode.name }},
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`
|
||||
);
|
||||
return this.#getAbodeById(aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
})(abode);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
UPDATE "abodes"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
"updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "aid" = ${{ uuid: abode.aid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getAbodeById(abode.aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async listResidents(): Promise<Resident[]> {
|
||||
return selectResidents(this.#db);
|
||||
}
|
||||
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
||||
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||
}
|
||||
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
||||
return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||
}
|
||||
#getResidentById(uid: string, aid: string): Resident {
|
||||
const resident = selectResident(
|
||||
this.#db,
|
||||
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`
|
||||
);
|
||||
if (!resident) throw new NotFoundAbodeError();
|
||||
return resident;
|
||||
}
|
||||
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
||||
return this.#getResidentById(uid, aid);
|
||||
}
|
||||
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
DELETE FROM "residents"
|
||||
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async createResident(
|
||||
resident: CreateResident,
|
||||
ctx: { uid: string }
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
this.#db.run(
|
||||
sql`
|
||||
INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: resident.uid }},
|
||||
${{ uuid: resident.aid }},
|
||||
${{ jsonb: resident.flags }},
|
||||
${{ uuid: ctx.uid }},
|
||||
${{ uuid: ctx.uid }}
|
||||
)
|
||||
`
|
||||
);
|
||||
return this.#getResidentById(resident.uid, resident.aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
async updateResident(
|
||||
resident: updateResident,
|
||||
ctx: { uid: string }
|
||||
): Promise<Resident> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||
})(resident);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(() => {
|
||||
const { changes } = this.#db.run(
|
||||
sql`
|
||||
UPDATE "residents"
|
||||
SET
|
||||
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
||||
"updated_by" = ${{ uuid: ctx.uid }}
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE
|
||||
"uid" = ${{ uuid: resident.uid }}
|
||||
AND "aid" = ${{ uuid: resident.aid }}
|
||||
`
|
||||
);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getResidentById(resident.uid, resident.aid);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async listUsersByAbodeId(id: string): Promise<ClientUser[]> {
|
||||
return selectClientUsers(
|
||||
this.#db,
|
||||
sql`
|
||||
JOIN "residents" r ON u."uid" = r."uid"
|
||||
WHERE r."aid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
}
|
||||
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
||||
return selectAbodes(
|
||||
this.#db,
|
||||
sql`
|
||||
JOIN "residents" r ON a."aid" = r."aid"
|
||||
WHERE r."uid" = ${{ uuid: id }}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
|
||||
const session = this.#db.get<{ uid: Buffer; expires_at: string }>(sql`
|
||||
SELECT "uid", "expires_at"
|
||||
FROM "sessions"
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
if (!session) throw new NotFoundAbodeError();
|
||||
if (new Date(sqliteToDate(session.expires_at)).getTime() < Date.now()) {
|
||||
if (!this.readonly) {
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "expires_at" < datetime('now', 'localtime', 'subsec')
|
||||
`);
|
||||
}
|
||||
throw new NotFoundAbodeError();
|
||||
}
|
||||
if (!this.readonly) {
|
||||
this.#db.run(sql`
|
||||
UPDATE "sessions"
|
||||
SET "expires_at" = datetime('now', 'localtime', 'subsec', '+7 days')
|
||||
WHERE "token" = ${{ text: token }}
|
||||
`);
|
||||
}
|
||||
return this.#getUserById(sqliteToUuid(session.uid));
|
||||
}
|
||||
async createSession(uid: string): Promise<`as_${string}`> {
|
||||
this.#checkReadonly();
|
||||
const token = createSessionToken();
|
||||
this.#db.run(sql`
|
||||
INSERT INTO "sessions"("uid", "token")
|
||||
VALUES(${{ uuid: uid }}, ${{ text: token }})
|
||||
`);
|
||||
return token;
|
||||
}
|
||||
async deleteSessionsByUser(uid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
this.#db.run(sql`
|
||||
DELETE FROM "sessions"
|
||||
WHERE "uid" = ${{ uuid: uid }}
|
||||
`);
|
||||
}
|
||||
|
||||
#getApikeyByToken(token: `at_${string}`): ClientApikey {
|
||||
const apikey = selectClientApikey(
|
||||
this.#db,
|
||||
sql`"token" = ${{ text: token }}`
|
||||
);
|
||||
if (!apikey) throw new NotFoundAbodeError();
|
||||
return apikey;
|
||||
}
|
||||
async getUserByApikey(
|
||||
token: `at_${string}`
|
||||
): Promise<[ClientUser, ClientApikey]> {
|
||||
const apikey = this.#getApikeyByToken(token);
|
||||
if (
|
||||
apikey.expires_at &&
|
||||
new Date(apikey.expires_at).getTime() < Date.now()
|
||||
) {
|
||||
throw new NotAuthorizedAbodeError();
|
||||
}
|
||||
return [this.#getUserById(apikey.uid), apikey];
|
||||
}
|
||||
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
||||
return selectClientApikeys(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||
}
|
||||
async getApikeyById(kid: string): Promise<ClientApikey> {
|
||||
const apikey = selectClientApikey(this.#db, sql`"kid" = ${{ uuid: kid }}`);
|
||||
if (!apikey) throw new NotFoundAbodeError();
|
||||
return apikey;
|
||||
}
|
||||
async createApikey(
|
||||
apikey: CreateApikey
|
||||
): Promise<[ClientApikey, `at_${string}`]> {
|
||||
this.#checkReadonly();
|
||||
const token = createApikeyToken();
|
||||
const kid = crypto.randomUUID();
|
||||
let expires = apikey.expires_at;
|
||||
if (expires === undefined)
|
||||
expires = new Date(
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365
|
||||
).toISOString();
|
||||
if (expires && new Date(expires).getTime() < Date.now())
|
||||
throw new InvalidAbodeError();
|
||||
|
||||
this.#db.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: apikey.uid }},
|
||||
${{ uuid: kid }},
|
||||
${{ text: token }},
|
||||
${{ text: apikey.name }},
|
||||
${{ jsonb: apikey.permissions }},
|
||||
${expires ? { date: expires } : { null: true }}
|
||||
)
|
||||
`);
|
||||
return [this.#getApikeyByToken(token), token];
|
||||
}
|
||||
async deleteApikeyById(kid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = this.#db.run(sql`
|
||||
DELETE FROM "apikeys"
|
||||
WHERE "kid" = ${{ uuid: kid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
AppliedMigration,
|
||||
AvailableMigration,
|
||||
Migrator,
|
||||
} from "../types/Migrator.js";
|
||||
import { init, migrations } from "./migrations/index.js";
|
||||
import { sqliteToDate } from "./cast.js";
|
||||
import type { WrappedDb } from "./impl/types.js";
|
||||
import { sql, unsafeSql } from "./sql.js";
|
||||
|
||||
export class SqliteMigrator implements Migrator {
|
||||
#db: WrappedDb;
|
||||
|
||||
constructor(db: WrappedDb) {
|
||||
this.#db = db;
|
||||
}
|
||||
|
||||
#listAppliedMigrations():
|
||||
| { id: number; name: string; applied_at: string }[]
|
||||
| null {
|
||||
try {
|
||||
return this.#db
|
||||
.all<{ id: number; name: string; applied_at: string }>(
|
||||
sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`
|
||||
)
|
||||
.map((x) => ({ ...x, applied_at: sqliteToDate(x.applied_at) }));
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "no such table: _migrations") {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async listAppliedMigrations(): Promise<AppliedMigration[]> {
|
||||
return this.#listAppliedMigrations() ?? [];
|
||||
}
|
||||
|
||||
listAvailableMigrations(): AvailableMigration[] {
|
||||
return migrations.map((migration) => ({
|
||||
id: migration.id,
|
||||
name: migration.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async migrateTo(id: number): Promise<void> {
|
||||
const target = migrations.find((x) => x.id === id);
|
||||
if (!target) throw new Error(`No known migration with id ${id}`);
|
||||
|
||||
let current = this.#listAppliedMigrations();
|
||||
if (!current) {
|
||||
this.#db.run(unsafeSql(init));
|
||||
current = [];
|
||||
}
|
||||
|
||||
for (const { id, name } of current) {
|
||||
const migration = migrations.find((x) => x.id === id);
|
||||
if (!migration)
|
||||
throw new Error(`Applied migration ${id} (${name}) not known`);
|
||||
if (migration.name !== name)
|
||||
throw new Error(
|
||||
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`
|
||||
);
|
||||
}
|
||||
|
||||
const start = migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
|
||||
const end = migrations.indexOf(target) + 1;
|
||||
|
||||
if (end < start) {
|
||||
throw new Error(
|
||||
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${
|
||||
target.id
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const toApply = migrations.slice(start, end);
|
||||
|
||||
if (!toApply.length) {
|
||||
console.log("Nothing to do");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const migration of toApply) {
|
||||
try {
|
||||
console.log(`Applying migration ${migration.id} (${migration.name})`);
|
||||
this.#db.run(sql`BEGIN`);
|
||||
for (const part of migration.parts) {
|
||||
console.log(`- Applying part ${part.id} (${part.name})`);
|
||||
if ("sql" in part) this.#db.run(unsafeSql(part.sql));
|
||||
else await part.apply(this.#db);
|
||||
}
|
||||
this.#db.run(
|
||||
sql`
|
||||
INSERT INTO "_migrations"("id", "name")
|
||||
VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
|
||||
`
|
||||
);
|
||||
this.#db.run(sql`COMMIT`);
|
||||
} catch (e) {
|
||||
this.#db.run(sql`ROLLBACK`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done migrating database");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Abode } from "../types/Abode.js";
|
||||
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
|
||||
import type { Resident, ResidentFlags } from "../types/Resident.js";
|
||||
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
|
||||
|
||||
export function dateToSqlite(date: string) {
|
||||
return new Date(date).getTime() / 1000;
|
||||
}
|
||||
export function sqliteToDate(date: string) {
|
||||
return new Date(date.replace(" ", "T") + "Z").toISOString();
|
||||
}
|
||||
|
||||
export function uuidToSqlite(uuid: string) {
|
||||
return Buffer.from(uuid.replaceAll("-", ""), "hex");
|
||||
}
|
||||
export function sqliteToUuid(uuid: Buffer | Uint8Array) {
|
||||
const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString(
|
||||
"hex"
|
||||
);
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
const defaultUserFlags: UserFlags = {};
|
||||
export function sqliteToUserFlags(flags: string): UserFlags {
|
||||
const parsed = JSON.parse(flags);
|
||||
const out = { ...defaultUserFlags };
|
||||
|
||||
if (typeof parsed !== "object" || !parsed || Array.isArray(parsed))
|
||||
return out;
|
||||
|
||||
if (parsed.admin === true) out.admin = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sqliteToPartialUser(user: {
|
||||
uid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): PartialUser {
|
||||
return {
|
||||
uid: sqliteToUuid(user.uid),
|
||||
name: user.name,
|
||||
flags: sqliteToUserFlags(user.flags),
|
||||
created_at: sqliteToDate(user.created_at),
|
||||
updated_at: sqliteToDate(user.updated_at),
|
||||
};
|
||||
}
|
||||
export function sqliteToClientUser(user: {
|
||||
uid: Buffer | Uint8Array;
|
||||
email: string;
|
||||
name: string;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): ClientUser {
|
||||
return {
|
||||
...sqliteToPartialUser(user),
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
export function sqliteToAbode(abode: {
|
||||
aid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
created_at: string;
|
||||
created_by: Buffer | Uint8Array | null;
|
||||
updated_at: string;
|
||||
updated_by: Buffer | Uint8Array | null;
|
||||
}): Abode {
|
||||
return {
|
||||
aid: sqliteToUuid(abode.aid),
|
||||
name: abode.name,
|
||||
created_at: sqliteToDate(abode.created_at),
|
||||
created_by: abode.created_by && sqliteToUuid(abode.created_by),
|
||||
updated_at: sqliteToDate(abode.updated_at),
|
||||
updated_by: abode.updated_by && sqliteToUuid(abode.updated_by),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultResidentFlags: ResidentFlags = {};
|
||||
export function sqliteToResidentFlags(flags: string): ResidentFlags {
|
||||
const parsed = JSON.parse(flags);
|
||||
const out = { ...defaultResidentFlags };
|
||||
|
||||
if (typeof parsed !== "object" || !parsed || Array.isArray(parsed))
|
||||
return out;
|
||||
|
||||
if (parsed.admin === true) out.admin = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sqliteToResident(resident: {
|
||||
uid: Buffer | Uint8Array;
|
||||
aid: Buffer | Uint8Array;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
created_by: Buffer | Uint8Array | null;
|
||||
updated_at: string;
|
||||
updated_by: Buffer | Uint8Array | null;
|
||||
}): Resident {
|
||||
return {
|
||||
uid: sqliteToUuid(resident.uid),
|
||||
aid: sqliteToUuid(resident.aid),
|
||||
flags: sqliteToResidentFlags(resident.flags),
|
||||
created_at: sqliteToDate(resident.created_at),
|
||||
created_by: resident.created_by && sqliteToUuid(resident.created_by),
|
||||
updated_at: sqliteToDate(resident.updated_at),
|
||||
updated_by: resident.updated_by && sqliteToUuid(resident.updated_by),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultApikeyPermissions: ApikeyPermissions = {};
|
||||
export function sqliteToApikeyPermissions(
|
||||
permissions: string
|
||||
): ApikeyPermissions {
|
||||
const parsed = JSON.parse(permissions);
|
||||
const out = { ...defaultApikeyPermissions };
|
||||
|
||||
if (typeof parsed !== "object" || !parsed || Array.isArray(parsed))
|
||||
return out;
|
||||
|
||||
if (parsed.admin === true) out.admin = true;
|
||||
if (parsed.all === true) out.all = true;
|
||||
for (const key of ["users", "residents", "abodes"] as const) {
|
||||
if (parsed[key] === "r" || parsed[key] === "rw") out[key] = parsed[key];
|
||||
}
|
||||
for (const key of ["restrict_users", "restrict_abodes"] as const) {
|
||||
if (
|
||||
Array.isArray(parsed[key]) &&
|
||||
parsed[key].every((x) => typeof x === "string")
|
||||
) {
|
||||
out[key] = parsed[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sqliteToClientApikey(apikey: {
|
||||
uid: Buffer | Uint8Array;
|
||||
kid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
permissions: string;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
}): ClientApikey {
|
||||
return {
|
||||
uid: sqliteToUuid(apikey.uid),
|
||||
kid: sqliteToUuid(apikey.kid),
|
||||
name: apikey.name,
|
||||
permissions: sqliteToApikeyPermissions(apikey.permissions),
|
||||
created_at: sqliteToDate(apikey.created_at),
|
||||
expires_at: apikey.expires_at ? sqliteToDate(apikey.expires_at) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GetDbDynamic } from "../types/GetDb.js";
|
||||
import { sqliteProtocols } from "./url.js";
|
||||
|
||||
const getSqlite = () =>
|
||||
import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then(
|
||||
(x) => x.default
|
||||
);
|
||||
|
||||
const getSqliteDynamic: GetDbDynamic = {
|
||||
name: "sqlite",
|
||||
protocols: sqliteProtocols,
|
||||
getSource: getSqlite,
|
||||
};
|
||||
export default getSqliteDynamic;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { GetDbStatic } from "../types/GetDb.js";
|
||||
import { getWrappedDb } from "./impl/index.js";
|
||||
import { SqliteInterface } from "./SqliteInterface.js";
|
||||
import { SqliteMigrator } from "./SqliteMigrator.js";
|
||||
import { isSqliteUrl, parseSqliteUrl, sqliteProtocols } from "./url.js";
|
||||
|
||||
const getSqliteStatic: GetDbStatic = {
|
||||
name: "sqlite",
|
||||
protocols: sqliteProtocols,
|
||||
checkUrl: isSqliteUrl,
|
||||
getDbInterface: async (url) =>
|
||||
new SqliteInterface(getWrappedDb(...parseSqliteUrl(url))),
|
||||
getMigrator: async (url) =>
|
||||
new SqliteMigrator(getWrappedDb(...parseSqliteUrl(url))),
|
||||
};
|
||||
export default getSqliteStatic;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ConflictAbodeError, NotFoundAbodeError } from "../../types/errors.js";
|
||||
import type { SqlCode, SqlVar } from "../sql.js";
|
||||
import Sqlite, * as sqlite from "better-sqlite3";
|
||||
import pragma from "../pragma.sqlite.sql";
|
||||
import type { WrappedDb, WrappedDbOptions } from "./types.js";
|
||||
|
||||
function getDatabase(
|
||||
path: string,
|
||||
options?: Omit<sqlite.Options, "nativeBinding">
|
||||
): sqlite.Database {
|
||||
if (!natives.sqlite) throw new Error("No natives found for better-sqlite3");
|
||||
options = { ...options };
|
||||
if (typeof options.timeout !== "number") delete options.timeout;
|
||||
const db = new Sqlite(path, { ...options, nativeBinding: natives.sqlite });
|
||||
db.exec(pragma);
|
||||
return db;
|
||||
}
|
||||
|
||||
function rethrow<R>(fn: () => R): R {
|
||||
try {
|
||||
return fn();
|
||||
} catch (e) {
|
||||
if (e instanceof sqlite.SqliteError) {
|
||||
switch (e.code) {
|
||||
case "SQLITE_CONSTRAINT_UNIQUE":
|
||||
case "SQLITE_CONSTRAINT_PRIMARYKEY":
|
||||
throw new ConflictAbodeError();
|
||||
|
||||
case "SQLITE_CONSTRAINT_FOREIGNKEY":
|
||||
throw new NotFoundAbodeError();
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export class WrappedBetterSqlite3Db implements WrappedDb {
|
||||
#db: sqlite.Database;
|
||||
#statements: Map<string, sqlite.Statement>;
|
||||
|
||||
constructor(path: string, options: WrappedDbOptions = {}) {
|
||||
this.#db = getDatabase(path, {
|
||||
readonly: options.readonly,
|
||||
timeout: options.timeout,
|
||||
});
|
||||
this.#statements = new Map();
|
||||
}
|
||||
|
||||
get _db() {
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this.#db.readonly;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#db.close();
|
||||
this.#statements.clear();
|
||||
}
|
||||
|
||||
#stmt<R>(stmt: string): sqlite.Statement<SqlVar[], R> {
|
||||
if (!this.#db.open) throw new Error("Db is closed");
|
||||
let prep = this.#statements.get(stmt);
|
||||
if (!prep) {
|
||||
prep = this.#db.prepare<(string | Buffer)[], R>(stmt);
|
||||
this.#statements.set(stmt, prep);
|
||||
}
|
||||
return prep as sqlite.Statement<string | Buffer, R>;
|
||||
}
|
||||
|
||||
all<R>(stmt: SqlCode): R[] {
|
||||
return this.#stmt<R>(stmt._sql).all(...stmt._vars);
|
||||
}
|
||||
get<R>(stmt: SqlCode): R | null {
|
||||
const results = this.all<R>(stmt);
|
||||
if (results.length > 1) throw new Error("Multiple results");
|
||||
if (!results.length) return null;
|
||||
return results[0];
|
||||
}
|
||||
run(stmt: SqlCode): { changes: number } {
|
||||
return this.#stmt<void>(stmt._sql).run(...stmt._vars);
|
||||
}
|
||||
|
||||
multi<R>(fn: () => R): R {
|
||||
return this.#db.transaction(fn)();
|
||||
}
|
||||
rethrow = rethrow;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { WrappedBetterSqlite3Db } from "./better-sqlite3.js";
|
||||
import { WrappedNodeSqliteDb } from "./node-sqlite.js";
|
||||
import type { WrappedDbConstructor } from "./types.js";
|
||||
|
||||
export const node: WrappedDbConstructor | null = WrappedNodeSqliteDb;
|
||||
export const bs3: WrappedDbConstructor | null = WrappedBetterSqlite3Db;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WrappedBetterSqlite3Db } from "./better-sqlite3.js";
|
||||
import type { WrappedDbConstructor } from "./types.js";
|
||||
|
||||
export const node: WrappedDbConstructor | null = null;
|
||||
export const bs3: WrappedDbConstructor | null = WrappedBetterSqlite3Db;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WrappedNodeSqliteDb } from "./node-sqlite.js";
|
||||
import type { WrappedDbConstructor } from "./types.js";
|
||||
|
||||
export const node: WrappedDbConstructor | null = WrappedNodeSqliteDb;
|
||||
export const bs3: WrappedDbConstructor | null = null;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./implementations.all.js";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WrappedDb, WrappedDbOptions } from "./types.js";
|
||||
import { node, bs3 } from "./implementations.js";
|
||||
|
||||
export function getWrappedDb(
|
||||
kind: "any" | "node" | "bs3",
|
||||
path: string,
|
||||
options: WrappedDbOptions
|
||||
): WrappedDb {
|
||||
if (kind === "node") {
|
||||
if (!node) throw new Error("Requesting unavailable node backend");
|
||||
return new node(path, options);
|
||||
} else if (kind === "bs3") {
|
||||
if (!bs3) throw new Error("Requesting unavailable better-sqlite3 backend");
|
||||
return new bs3(path, options);
|
||||
} else {
|
||||
const impl = [node, bs3].find(Boolean);
|
||||
if (!impl) throw new Error("No available backend");
|
||||
return new impl(path, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
import type { WrappedDb, WrappedDbOptions } from "./types.js";
|
||||
import type { SqlCode } from "../sql.js";
|
||||
import pragma from "../pragma.sqlite.sql";
|
||||
|
||||
export class WrappedNodeSqliteDb implements WrappedDb {
|
||||
#db: DatabaseSync;
|
||||
#readonly: boolean;
|
||||
#statements: Map<string, StatementSync>;
|
||||
|
||||
constructor(path: string, options: WrappedDbOptions = {}) {
|
||||
this.#readonly = !!options.readonly;
|
||||
this.#db = new DatabaseSync(path, {
|
||||
readOnly: options.readonly,
|
||||
timeout: options.timeout,
|
||||
open: true,
|
||||
});
|
||||
this.#db.exec(pragma);
|
||||
this.#statements = new Map();
|
||||
}
|
||||
|
||||
get _db() {
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
get readonly(): boolean {
|
||||
return this.#readonly;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#db.close();
|
||||
this.#statements.clear();
|
||||
}
|
||||
|
||||
#stmt(stmt: string): StatementSync {
|
||||
if (!this.#db.open) throw new Error("Db is closed");
|
||||
let prep = this.#statements.get(stmt);
|
||||
if (!prep) {
|
||||
prep = this.#db.prepare(stmt);
|
||||
prep.setReadBigInts(false);
|
||||
this.#statements.set(stmt, prep);
|
||||
}
|
||||
return prep;
|
||||
}
|
||||
|
||||
all<R>(stmt: SqlCode): R[] {
|
||||
return this.#stmt(stmt._sql).all(...stmt._vars) as R[];
|
||||
}
|
||||
get<R>(stmt: SqlCode): R | null {
|
||||
const results = this.all<R>(stmt);
|
||||
if (results.length > 1) throw new Error("Multiple results");
|
||||
if (!results.length) return null;
|
||||
return results[0];
|
||||
}
|
||||
run(stmt: SqlCode): { changes: number } {
|
||||
const { changes } = this.#stmt(stmt._sql).run(...stmt._vars);
|
||||
return { changes: Number(changes) };
|
||||
}
|
||||
|
||||
multi<R>(fn: () => R): R {
|
||||
if (this.#db.isTransaction)
|
||||
throw new Error("Nested transactions not supported");
|
||||
this.#db.exec("BEGIN");
|
||||
try {
|
||||
const rst = fn();
|
||||
this.#db.exec("COMMIT");
|
||||
return rst;
|
||||
} catch (e) {
|
||||
this.#db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
rethrow<R>(fn: () => R): R {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SqlCode } from "../sql.js";
|
||||
|
||||
export interface WrappedDb {
|
||||
get readonly(): boolean;
|
||||
destroy(): void;
|
||||
|
||||
all<R>(stmt: SqlCode): R[];
|
||||
get<R>(stmt: SqlCode): R | null;
|
||||
run(stmt: SqlCode): { changes: number };
|
||||
multi<R>(fn: () => R): R;
|
||||
rethrow<R>(fn: () => R): R;
|
||||
}
|
||||
|
||||
export type WrappedDbOptions = { readonly?: boolean; timeout?: number };
|
||||
|
||||
export interface WrappedDbConstructor {
|
||||
new (path: string, options?: WrappedDbOptions): WrappedDb;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "users" (
|
||||
"uid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||
"email" TEXT NOT NULL UNIQUE,
|
||||
"name" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL DEFAULT '#unset',
|
||||
"flags" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec'))
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "abodes" (
|
||||
"aid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||
"name" TEXT NOT NULL,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"created_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"updated_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL -- uuid
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "residents" (
|
||||
"uid" BLOB NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||
"aid" BLOB NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE,
|
||||
"flags" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"created_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"updated_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||
|
||||
PRIMARY KEY("uid", "aid")
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SqliteMigration } from "../types.js";
|
||||
import p1 from "./1.users.sqlite.sql";
|
||||
import p2 from "./2.abodes.sqlite.sql";
|
||||
import p3 from "./3.residents.sqlite.sql";
|
||||
|
||||
export const m1: SqliteMigration = {
|
||||
id: 1,
|
||||
name: "init",
|
||||
parts: [
|
||||
{
|
||||
id: 1,
|
||||
name: "users",
|
||||
sql: p1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "abodes",
|
||||
sql: p2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "residents",
|
||||
sql: p3,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "sessions" (
|
||||
"uid" BLOB NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE, -- uuid
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"expires_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec', '+7 days'))
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "apikeys" (
|
||||
"uid" BLOB NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE, -- uuid
|
||||
"kid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||
"token" TEXT NOT NULL UNIQUE,
|
||||
"name" TEXT NOT NULL,
|
||||
"permissions" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"expires_at" TEXT
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SqliteMigration } from "../types.js";
|
||||
import p1 from "./1.sessions.sqlite.sql";
|
||||
import p2 from "./2.apikeys.sqlite.sql";
|
||||
|
||||
export const m2: SqliteMigration = {
|
||||
id: 2,
|
||||
name: "auth",
|
||||
parts: [
|
||||
{
|
||||
id: 1,
|
||||
name: "sessions",
|
||||
sql: p1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "apikeys",
|
||||
sql: p2,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "notes" (
|
||||
"nid" BLOB NOT NULL PRIMARY KEY, -- uuid
|
||||
"aid" BLOB NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE, -- uuid
|
||||
"name" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL DEFAULT '', -- markdown
|
||||
"properties" BLOB NOT NULL DEFAULT (jsonb('{}')), -- JSONB
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"created_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL, -- uuid
|
||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec')),
|
||||
"updated_by" BLOB REFERENCES "users"("uid") ON DELETE SET NULL -- uuid
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { SqliteMigration } from "../types.js";
|
||||
import p1 from "./1.notes.sql";
|
||||
|
||||
export const m3: SqliteMigration = {
|
||||
id: 3,
|
||||
name: "notes",
|
||||
parts: [
|
||||
{
|
||||
id: 1,
|
||||
name: "notes",
|
||||
sql: p1,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { m1 } from "./1.init/index.js";
|
||||
import { m2 } from "./2.auth/index.js";
|
||||
import { m3 } from "./3.notes/index.js";
|
||||
import type { SqliteMigration } from "./types.js";
|
||||
|
||||
export { default as init } from "./init.sqlite.sql";
|
||||
export const migrations: SqliteMigration[] = [m1, m2, m3];
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE "_migrations" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"applied_at" TEXT NOT NULL DEFAULT (datetime('now', 'localtime', 'subsec'))
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WrappedDb } from "../impl/types.js";
|
||||
|
||||
export type SqliteMigrationPart = {
|
||||
id: number;
|
||||
name: string;
|
||||
} & (
|
||||
| {
|
||||
sql: string;
|
||||
}
|
||||
| {
|
||||
apply: (database: WrappedDb) => Promise<void>;
|
||||
}
|
||||
);
|
||||
|
||||
export type SqliteMigration = {
|
||||
id: number;
|
||||
name: string;
|
||||
parts: SqliteMigrationPart[];
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Abode } from "../types/Abode.js";
|
||||
import type { ClientApikey } from "../types/Apikey.js";
|
||||
import type { Resident } from "../types/Resident.js";
|
||||
import type { ClientUser } from "../types/User.js";
|
||||
import {
|
||||
sqliteToAbode,
|
||||
sqliteToClientApikey,
|
||||
sqliteToClientUser,
|
||||
sqliteToResident,
|
||||
} from "./cast.js";
|
||||
import type { WrappedDb } from "./impl/types.js";
|
||||
import { sql, type SqlCode } from "./sql.js";
|
||||
|
||||
type RawClientUser = {
|
||||
uid: Buffer | Uint8Array;
|
||||
email: string;
|
||||
name: string;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
const sqlClientUser = sql`
|
||||
SELECT u."uid", u."email", u."name", json(u."flags") AS "flags", u."created_at", u."updated_at"
|
||||
FROM "users" u
|
||||
`;
|
||||
|
||||
export function selectClientUser(
|
||||
db: WrappedDb,
|
||||
where: SqlCode
|
||||
): ClientUser | null {
|
||||
const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
|
||||
if (rawUser) return sqliteToClientUser(rawUser);
|
||||
return null;
|
||||
}
|
||||
export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] {
|
||||
const rawUsers = db.all<RawClientUser>(
|
||||
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser
|
||||
);
|
||||
return rawUsers.map(sqliteToClientUser);
|
||||
}
|
||||
|
||||
type RawAbode = {
|
||||
aid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
created_at: string;
|
||||
created_by: Buffer | Uint8Array | null;
|
||||
updated_at: string;
|
||||
updated_by: Buffer | Uint8Array | null;
|
||||
};
|
||||
const sqlAbode = sql`
|
||||
SELECT a."aid", a."name", a."created_at", a."created_by", a."updated_at", a."updated_by"
|
||||
FROM "abodes" a
|
||||
`;
|
||||
|
||||
export function selectAbode(db: WrappedDb, where: SqlCode): Abode | null {
|
||||
const rawAbode = db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`);
|
||||
if (rawAbode) return sqliteToAbode(rawAbode);
|
||||
return null;
|
||||
}
|
||||
export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] {
|
||||
const rawAbodes = db.all<RawAbode>(
|
||||
rest ? sql`${sqlAbode} ${rest}` : sqlAbode
|
||||
);
|
||||
return rawAbodes.map(sqliteToAbode);
|
||||
}
|
||||
|
||||
type RawResident = {
|
||||
uid: Buffer | Uint8Array;
|
||||
aid: Buffer | Uint8Array;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
created_by: Buffer | Uint8Array | null;
|
||||
updated_at: string;
|
||||
updated_by: Buffer | Uint8Array | null;
|
||||
};
|
||||
const sqlResident = sql`
|
||||
SELECT "uid", "aid", json("flags") AS "flags", "created_at", "created_by", "updated_at", "updated_by"
|
||||
FROM "residents"
|
||||
`;
|
||||
|
||||
export function selectResident(db: WrappedDb, where: SqlCode): Resident | null {
|
||||
const rawResident = db.get<RawResident>(sql`${sqlResident} WHERE ${where}`);
|
||||
if (rawResident) return sqliteToResident(rawResident);
|
||||
return null;
|
||||
}
|
||||
export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] {
|
||||
const rawResidents = db.all<RawResident>(
|
||||
where ? sql`${sqlResident} WHERE ${where}` : sqlResident
|
||||
);
|
||||
return rawResidents.map(sqliteToResident);
|
||||
}
|
||||
|
||||
type RawClientApikey = {
|
||||
uid: Buffer | Uint8Array;
|
||||
kid: Buffer | Uint8Array;
|
||||
name: string;
|
||||
permissions: string;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
};
|
||||
const sqlClientApikey = sql`
|
||||
SELECT k."uid", k."kid", k."name", json(k."permissions") AS "permissions", k."created_at", k."expires_at"
|
||||
FROM "apikeys" k
|
||||
`;
|
||||
|
||||
export function selectClientApikey(
|
||||
db: WrappedDb,
|
||||
where: SqlCode
|
||||
): ClientApikey | null {
|
||||
const rawApikey = db.get<RawClientApikey>(
|
||||
sql`${sqlClientApikey} WHERE ${where}`
|
||||
);
|
||||
if (rawApikey) return sqliteToClientApikey(rawApikey);
|
||||
return null;
|
||||
}
|
||||
export function selectClientApikeys(
|
||||
db: WrappedDb,
|
||||
where: SqlCode
|
||||
): ClientApikey[] {
|
||||
const rawApikeys = db.all<RawClientApikey>(
|
||||
sql`${sqlClientApikey} WHERE ${where}`
|
||||
);
|
||||
return rawApikeys.map(sqliteToClientApikey);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { uuidToSqlite } from "./cast.js";
|
||||
|
||||
export type SqlVar = string | Buffer | number;
|
||||
export type SqlCode = { _sql: string; _vars: SqlVar[] };
|
||||
type SqlArg =
|
||||
| { uuid: string }
|
||||
| { text: string }
|
||||
| { jsonb: unknown }
|
||||
| { date: string }
|
||||
| { int: number }
|
||||
| { null: true }
|
||||
| SqlCode;
|
||||
export function sql(text: TemplateStringsArray, ...args: SqlArg[]): SqlCode {
|
||||
let code = "";
|
||||
const vars: SqlVar[] = [];
|
||||
for (const [i, part] of text.entries()) {
|
||||
code += part;
|
||||
if (i < args.length) {
|
||||
const arg = args[i];
|
||||
if ("uuid" in arg) {
|
||||
code += "?";
|
||||
vars.push(uuidToSqlite(arg.uuid));
|
||||
} else if ("text" in arg) {
|
||||
code += "?";
|
||||
vars.push(arg.text);
|
||||
} else if ("jsonb" in arg) {
|
||||
code += "jsonb(?)";
|
||||
vars.push(JSON.stringify(arg.jsonb));
|
||||
} else if ("date" in arg) {
|
||||
code += "datetime(?, 'unixepoch', 'subsec')";
|
||||
vars.push(new Date(arg.date).getTime() / 1000 + "");
|
||||
} else if ("int" in arg) {
|
||||
if (arg.int % 1) throw new Error("Not an integer");
|
||||
code += "?";
|
||||
vars.push(arg.int);
|
||||
} else if ("null" in arg) {
|
||||
code += "NULL";
|
||||
} else {
|
||||
code += arg._sql;
|
||||
for (const v of arg._vars) vars.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { _sql: code, _vars: vars };
|
||||
}
|
||||
export function catSql(a: SqlCode, b: SqlCode): SqlCode {
|
||||
return {
|
||||
_sql: a._sql + b._sql,
|
||||
_vars: [...a._vars, ...b._vars],
|
||||
};
|
||||
}
|
||||
export function joinSql(parts: SqlCode[], joiner: SqlCode): SqlCode {
|
||||
return parts.reduce((a, b) => catSql(catSql(a, joiner), b));
|
||||
}
|
||||
|
||||
export function unsafeSql(sql: string): SqlCode {
|
||||
return { _sql: sql, _vars: [] };
|
||||
}
|
||||
|
||||
export function calcUpdates<T extends object>(updater: {
|
||||
[K in keyof T]: (value: NonNullable<T[K]>) => SqlCode;
|
||||
}): (obj: Partial<T>) => SqlCode[] {
|
||||
return (obj) => {
|
||||
const updates: SqlCode[] = [];
|
||||
for (const [prop, update] of Object.entries(updater)) {
|
||||
if (prop in obj) {
|
||||
updates.push(
|
||||
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!)
|
||||
);
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { WrappedDbOptions } from "./impl/types.js";
|
||||
|
||||
function checkBooleanParam(url: URL, param: string): boolean {
|
||||
return (url.searchParams.get(param) ?? "0") !== "0";
|
||||
}
|
||||
|
||||
function checkNumberParam(url: URL, param: string): number | undefined {
|
||||
const value = url.searchParams.get(param);
|
||||
if (!value || isNaN(+value)) return;
|
||||
return +value;
|
||||
}
|
||||
|
||||
export const sqliteProtocols = [
|
||||
"sqlite:",
|
||||
"sqlite3:",
|
||||
"node+sqlite:",
|
||||
"bs3+sqlite:",
|
||||
];
|
||||
|
||||
export function isSqliteUrl(url: string) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return sqliteProtocols.includes(urlObj.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSqliteUrl(
|
||||
url: string
|
||||
): ["any" | "node" | "bs3", string, WrappedDbOptions] {
|
||||
if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol");
|
||||
const urlObj = new URL(url);
|
||||
const options: WrappedDbOptions = {
|
||||
readonly: checkBooleanParam(urlObj, "readonly"),
|
||||
timeout: checkNumberParam(urlObj, "timeout"),
|
||||
};
|
||||
const kind =
|
||||
urlObj.protocol === "node+sqlite:"
|
||||
? "node"
|
||||
: urlObj.protocol === "bs3+sqlite:"
|
||||
? "bs3"
|
||||
: "any";
|
||||
return [kind, urlObj.pathname, options];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GetDbDynamic, GetDbStatic } from "./types/GetDb.js";
|
||||
|
||||
const getStub: GetDbStatic & GetDbDynamic = {
|
||||
name: "stub",
|
||||
protocols: [],
|
||||
checkUrl: () => false,
|
||||
getDbInterface: async () => {
|
||||
throw new Error("Stub db interface");
|
||||
},
|
||||
getMigrator: async () => {
|
||||
throw new Error("Stub db interface");
|
||||
},
|
||||
getSource: async () => null,
|
||||
};
|
||||
export default getStub;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Create, Update } from "./utils.js";
|
||||
|
||||
export type Abode = {
|
||||
aid: string; // PK, uuid
|
||||
name: string;
|
||||
created_at: string; // ISO datetime
|
||||
created_by: string | null; // uuid, FK Users.uid
|
||||
updated_at: string; // ISO datetime
|
||||
updated_by: string | null; // uuid, FK Users.uid
|
||||
};
|
||||
|
||||
export type CreateAbode = Create<Abode, "aid">;
|
||||
export type UpdateAbode = Update<Abode, "aid">;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Create } from "./utils.js";
|
||||
|
||||
export type ApikeyPermissions = {
|
||||
admin?: boolean;
|
||||
all?: boolean;
|
||||
users?: "r" | "rw";
|
||||
residents?: "r" | "rw";
|
||||
abodes?: "r" | "rw";
|
||||
restrict_users?: string[]; // uuid, FK users.uid
|
||||
restrict_abodes?: string[]; // uuid, FK abodes.aid
|
||||
};
|
||||
|
||||
export type Apikey = {
|
||||
uid: string; // uuid, FK users.uid
|
||||
kid: string; // uuid, PK
|
||||
token: string; // unique
|
||||
name: string;
|
||||
permissions: ApikeyPermissions;
|
||||
created_at: string; // ISO datetime
|
||||
expires_at: string | null; // ISO datetime
|
||||
};
|
||||
|
||||
export type ClientApikey = Omit<Apikey, "token">;
|
||||
export type CreateApikey = Create<Apikey, "kid" | "token"> &
|
||||
Partial<Pick<Apikey, "expires_at">>;
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Abode, CreateAbode, UpdateAbode } from "./Abode.js";
|
||||
import type { ClientApikey, CreateApikey } from "./Apikey.js";
|
||||
import type { CreateNote, Note, PartialNote, UpdateNote } from "./Note.js";
|
||||
import type { CreateResident, Resident, updateResident } from "./Resident.js";
|
||||
import type {
|
||||
ClientUser,
|
||||
CreateUser,
|
||||
LoginUser,
|
||||
PartialUser,
|
||||
UpdateUser,
|
||||
} from "./User.js";
|
||||
|
||||
export interface DbInterface {
|
||||
readonly: boolean;
|
||||
backend: boolean;
|
||||
name: string;
|
||||
|
||||
close(): Promise<void>;
|
||||
|
||||
// CRUD users
|
||||
listUsers(): Promise<(PartialUser | ClientUser)[]>;
|
||||
getUserById(uid: string): Promise<PartialUser | ClientUser>;
|
||||
deleteUserById(uid: string): Promise<void>;
|
||||
createUser(user: CreateUser): Promise<ClientUser>;
|
||||
updateUser(user: UpdateUser): Promise<ClientUser>;
|
||||
|
||||
// get user by other properties
|
||||
getUserByEmail(email: string): Promise<PartialUser | ClientUser>;
|
||||
|
||||
// CRUD abodes
|
||||
listAbodes(): Promise<Abode[]>;
|
||||
getAbodeById(aid: string): Promise<Abode>;
|
||||
deleteAbodeById(aid: string): Promise<void>;
|
||||
createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode>;
|
||||
updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode>;
|
||||
|
||||
// CRUD residents
|
||||
listResidents(): Promise<Resident[]>;
|
||||
getResidentById(uid: string, aid: string): Promise<Resident>;
|
||||
deleteResidentById(uid: string, aid: string): Promise<void>;
|
||||
createResident(
|
||||
resident: CreateResident,
|
||||
ctx: { uid: string }
|
||||
): Promise<Resident>;
|
||||
updateResident(
|
||||
resident: updateResident,
|
||||
ctx: { uid: string }
|
||||
): Promise<Resident>;
|
||||
|
||||
// list residents by member
|
||||
listResidentsByUserId(uid: string): Promise<Resident[]>;
|
||||
listResidentsByAbodeId(aid: string): Promise<Resident[]>;
|
||||
|
||||
// list users/abodes through residents
|
||||
listUsersByAbodeId(aid: string): Promise<(PartialUser | ClientUser)[]>;
|
||||
listAbodesByUserId(uid: string): Promise<Abode[]>;
|
||||
|
||||
// CRUD notes
|
||||
listNotes(): Promise<PartialNote[]>;
|
||||
getNoteById(nid: string): Promise<Note>;
|
||||
deleteNoteById(nid: string): Promise<void>;
|
||||
createNote(note: CreateNote, ctx: { uid: string }): Promise<Note>;
|
||||
updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note>;
|
||||
|
||||
// list notes by access
|
||||
listNotesByAbodeId(aid: string): Promise<PartialNote[]>;
|
||||
listNotesByUserId(uid: string): Promise<PartialNote[]>;
|
||||
|
||||
// auth by session
|
||||
deleteSessionsByUser(uid: string): Promise<void>;
|
||||
|
||||
// auth by apikey
|
||||
listApikeysByUser(uid: string): Promise<ClientApikey[]>;
|
||||
getApikeyById(kid: string): Promise<ClientApikey>;
|
||||
createApikey(apikey: CreateApikey): Promise<[ClientApikey, `at_${string}`]>;
|
||||
deleteApikeyById(kid: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BackendDbInterface extends DbInterface {
|
||||
backend: true;
|
||||
|
||||
// auth by email/password
|
||||
getUserByLogin(login: LoginUser): Promise<ClientUser>;
|
||||
|
||||
// auth by session
|
||||
getUserBySession(token: `as_${string}`): Promise<ClientUser>;
|
||||
createSession(uid: string): Promise<`as_${string}`>;
|
||||
|
||||
// auth by apikey
|
||||
getUserByApikey(token: `at_${string}`): Promise<[ClientUser, ClientApikey]>;
|
||||
}
|
||||
|
||||
export function isBackendInterface(db: DbInterface): db is BackendDbInterface {
|
||||
return (
|
||||
db.backend &&
|
||||
(
|
||||
[
|
||||
"getUserByLogin",
|
||||
"getUserBySession",
|
||||
"createSession",
|
||||
"getUserByApikey",
|
||||
] as const
|
||||
).every(
|
||||
(x) =>
|
||||
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DbInterface } from "./DbInterface.js";
|
||||
import type { Migrator } from "./Migrator.js";
|
||||
|
||||
export interface GetDbStatic {
|
||||
name: string;
|
||||
protocols: string[];
|
||||
checkUrl(url: string): boolean;
|
||||
getDbInterface(url: string): Promise<DbInterface>;
|
||||
getMigrator(url: string): Promise<Migrator>;
|
||||
}
|
||||
|
||||
export interface GetDbDynamic {
|
||||
name: string;
|
||||
protocols: string[];
|
||||
getSource(url: string): Promise<GetDbStatic | null>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type AppliedMigration = {
|
||||
id: number; // PK
|
||||
name: string;
|
||||
applied_at: string; // isodatetime
|
||||
};
|
||||
|
||||
export type AvailableMigration = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export interface Migrator {
|
||||
listAppliedMigrations(): Promise<AppliedMigration[]>;
|
||||
listAvailableMigrations(): AvailableMigration[];
|
||||
migrateTo(id: number): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Create, Update } from "./utils.js";
|
||||
|
||||
export type NoteType = "note";
|
||||
|
||||
export type NoteProperties = {
|
||||
/**
|
||||
* @default 'note'
|
||||
*/
|
||||
type?: NoteType;
|
||||
};
|
||||
|
||||
export type PartialNoteProperties = Required<Pick<NoteProperties, "type">>;
|
||||
|
||||
export type Note = {
|
||||
nid: string; // PK, uuid
|
||||
aid: string; // uuid, FK Abodes.aid
|
||||
name: string;
|
||||
content: string; // markdown
|
||||
properties: NoteProperties;
|
||||
created_at: string; // ISO datetime
|
||||
created_by: string | null; // uuid, FK Users.uid
|
||||
updated_at: string; // ISO datetime
|
||||
updated_by: string | null; // uuid, FK Users.uid
|
||||
};
|
||||
|
||||
export type PartialNote = Omit<Note, "content" | "properties"> & {
|
||||
properties: PartialNoteProperties;
|
||||
};
|
||||
|
||||
export type CreateNote = Create<
|
||||
Omit<Note, "properties"> & { properties: PartialNoteProperties },
|
||||
"nid"
|
||||
>;
|
||||
export type UpdateNote = Update<
|
||||
Omit<Note, "properties" | "aid"> & { properties: PartialNoteProperties },
|
||||
"nid"
|
||||
>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Create, Update } from "./utils.js";
|
||||
|
||||
export type ResidentFlags = {
|
||||
admin?: boolean;
|
||||
};
|
||||
|
||||
export type Resident = {
|
||||
uid: string; // PK, uuid, FK User.uid
|
||||
aid: string; // PK, uuid, FK Abode.aid
|
||||
flags: ResidentFlags;
|
||||
created_at: string; // ISO datetime
|
||||
created_by: string | null; // uuid, FK Users.uid
|
||||
updated_at: string; // ISO datetime
|
||||
updated_by: string | null; // uuid, FK Users.uid
|
||||
};
|
||||
|
||||
export type CreateResident = Create<Resident, never>;
|
||||
export type updateResident = Update<Resident, "uid" | "aid">;
|
||||
@@ -0,0 +1,7 @@
|
||||
export type Session = {
|
||||
uid: string; // uuid, FK users.uid
|
||||
token: string; // PK
|
||||
created_at: string; // ISO datetime
|
||||
updated_at: string; // ISO datetime
|
||||
expires_at: string; // ISO datetime
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Create, Update } from "./utils.js";
|
||||
|
||||
export type UserFlags = {
|
||||
admin?: boolean;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
uid: string; // PK, uuid
|
||||
email: string; // email
|
||||
name: string;
|
||||
password:
|
||||
| `#${"unset"}` // special state
|
||||
| `$${string}$${string}`; // hashed password
|
||||
flags: UserFlags;
|
||||
created_at: string; // ISO datetime
|
||||
updated_at: string; // ISO datetime
|
||||
};
|
||||
|
||||
export type PartialUser = Omit<User, "email" | "password">;
|
||||
export type ClientUser = Omit<User, "password">;
|
||||
export type CreateUser = Create<User, "uid">;
|
||||
export type UpdateUser = Update<User, "uid">;
|
||||
|
||||
export type LoginUser = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export function isValidUserPassword(
|
||||
password: string
|
||||
): password is User["password"] {
|
||||
if (password.startsWith("#")) {
|
||||
return ["unset"].includes(password.slice(1));
|
||||
} else {
|
||||
return !!password.match(/^\$.+\$.+$/);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class AbodeError extends Error {}
|
||||
|
||||
export class NotFoundAbodeError extends AbodeError {}
|
||||
export class NotAuthorizedAbodeError extends AbodeError {}
|
||||
export class ConflictAbodeError extends AbodeError {}
|
||||
export class ReadonlyAbodeError extends AbodeError {}
|
||||
export class InvalidAbodeError extends AbodeError {}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type WithoutMetadata<T> = Omit<T, `${string}_at` | `${string}_by`>;
|
||||
export type Update<T, K extends keyof T> = Partial<WithoutMetadata<T>> &
|
||||
Pick<T, K>;
|
||||
export type Create<T, K extends keyof T> = Omit<WithoutMetadata<T>, K>;
|
||||
Reference in New Issue
Block a user