feat: initial commit

This commit is contained in:
2026-06-29 23:07:14 +00:00
commit 3b9a6bc85c
152 changed files with 12558 additions and 0 deletions
+481
View File
@@ -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");
}
}
+108
View File
@@ -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");
}
}
+162
View File
@@ -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,
};
}
View File
+14
View File
@@ -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;
+16
View File
@@ -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;
+89
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export * from "./implementations.all.js";
+20
View File
@@ -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);
}
}
+76
View File
@@ -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();
}
}
+18
View File
@@ -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")
);
+26
View File
@@ -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
);
+20
View File
@@ -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
);
+14
View File
@@ -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,
},
],
};
+7
View File
@@ -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];
+5
View File
@@ -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'))
);
+19
View File
@@ -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[];
};
+4
View File
@@ -0,0 +1,4 @@
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
+124
View File
@@ -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);
}
+74
View File
@@ -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;
};
}
+45
View File
@@ -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];
}