feat: add PostgreSQL backend
Mirrors the node:sqlite sub-backend structure with full migration support. Uses native pg types (UUID, JSONB, TIMESTAMPTZ) and $1/$2 parameterisation via internal ? placeholders converted at execution time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/koa": "^3.0.0",
|
"@types/koa": "^3.0.0",
|
||||||
"@types/koa__router": "^12.0.4",
|
"@types/koa__router": "^12.0.4",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import getApiStatic from "./api/getdb.static.js";
|
import getApiStatic from "./api/getdb.static.js";
|
||||||
|
import getPgStatic from "./postgres/getdb.static.js";
|
||||||
import getSqliteStatic from "./sqlite/getdb.static.js";
|
import getSqliteStatic from "./sqlite/getdb.static.js";
|
||||||
import type { GetDbStatic } from "./types/GetDb.js";
|
import type { GetDbStatic } from "./types/GetDb.js";
|
||||||
|
|
||||||
const dbSources: GetDbStatic[] = [getSqliteStatic, getApiStatic];
|
const dbSources: GetDbStatic[] = [getSqliteStatic, getPgStatic, getApiStatic];
|
||||||
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
|
||||||
void url;
|
void url;
|
||||||
return dbSources;
|
return dbSources;
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import type { BackendDbInterface } from "../types/DbInterface.js";
|
||||||
|
import {
|
||||||
|
isValidUserPassword,
|
||||||
|
type ClientUser,
|
||||||
|
type CreateUser,
|
||||||
|
type LoginUser,
|
||||||
|
type UpdateUser,
|
||||||
|
type UserFlags,
|
||||||
|
} from "../types/User.js";
|
||||||
|
import { pgToClientUser, pgToDate } from "./cast.js";
|
||||||
|
import {
|
||||||
|
ConflictAbodeError,
|
||||||
|
InvalidAbodeError,
|
||||||
|
NotAuthorizedAbodeError,
|
||||||
|
NotFoundAbodeError,
|
||||||
|
ReadonlyAbodeError,
|
||||||
|
} from "../types/errors.js";
|
||||||
|
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
||||||
|
import type {
|
||||||
|
CreateResident,
|
||||||
|
Resident,
|
||||||
|
ResidentFlags,
|
||||||
|
updateResident,
|
||||||
|
} from "../types/Resident.js";
|
||||||
|
import { validatePassword } from "../../util/hash.js";
|
||||||
|
import { createApikeyToken, createSessionToken } from "../../util/token.js";
|
||||||
|
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
||||||
|
import { calcUpdates, catSql, joinSql, sql } from "./sql.js";
|
||||||
|
import {
|
||||||
|
selectAbode,
|
||||||
|
selectAbodes,
|
||||||
|
selectClientApikey,
|
||||||
|
selectClientApikeys,
|
||||||
|
selectClientUser,
|
||||||
|
selectClientUsers,
|
||||||
|
selectResident,
|
||||||
|
selectResidents,
|
||||||
|
} from "./query.js";
|
||||||
|
import type {
|
||||||
|
CreateNote,
|
||||||
|
Note,
|
||||||
|
PartialNote,
|
||||||
|
UpdateNote,
|
||||||
|
} from "../types/Note.js";
|
||||||
|
import type { WrappedPgClient } from "./pool.js";
|
||||||
|
|
||||||
|
export class PostgresInterface implements BackendDbInterface {
|
||||||
|
#db: WrappedPgClient;
|
||||||
|
|
||||||
|
constructor(db: WrappedPgClient) {
|
||||||
|
this.#db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
#checkReadonly(): void {
|
||||||
|
if (this.#db.readonly) throw new ReadonlyAbodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
get _() {
|
||||||
|
return {
|
||||||
|
sql,
|
||||||
|
catSql,
|
||||||
|
joinSql,
|
||||||
|
db: this.#db,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get readonly(): boolean {
|
||||||
|
return this.#db.readonly;
|
||||||
|
}
|
||||||
|
get backend(): true {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
get name(): "postgres" {
|
||||||
|
return "postgres";
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.#db.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers(): Promise<ClientUser[]> {
|
||||||
|
return selectClientUsers(this.#db);
|
||||||
|
}
|
||||||
|
async #getUserById(uid: string, db: WrappedPgClient): Promise<ClientUser> {
|
||||||
|
const user = await selectClientUser(db, sql`u."uid" = ${{ uuid: uid }}`);
|
||||||
|
if (!user) throw new NotFoundAbodeError();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
async getUserById(id: string): Promise<ClientUser> {
|
||||||
|
return this.#getUserById(id, this.#db);
|
||||||
|
}
|
||||||
|
async getUserByEmail(email: string): Promise<ClientUser> {
|
||||||
|
const user = await selectClientUser(
|
||||||
|
this.#db,
|
||||||
|
sql`u."email" = ${{ text: email }}`
|
||||||
|
);
|
||||||
|
if (!user) throw new NotFoundAbodeError();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
|
||||||
|
const rawUser = await this.#db.get<{
|
||||||
|
uid: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
flags: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
password: string;
|
||||||
|
}>(
|
||||||
|
sql`
|
||||||
|
SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password"
|
||||||
|
FROM "users"
|
||||||
|
WHERE "email" = ${{ text: email }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!rawUser) throw new NotFoundAbodeError();
|
||||||
|
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
|
||||||
|
if (!(await validatePassword(password, rawUser.password)))
|
||||||
|
throw new NotAuthorizedAbodeError();
|
||||||
|
return pgToClientUser(rawUser);
|
||||||
|
}
|
||||||
|
async deleteUserById(id: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = await this.#db.run(
|
||||||
|
sql`
|
||||||
|
DELETE FROM "users"
|
||||||
|
WHERE "uid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
async createUser(user: CreateUser): Promise<ClientUser> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
|
||||||
|
const uid = crypto.randomUUID();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(async (tx) => {
|
||||||
|
await tx.run(
|
||||||
|
sql`
|
||||||
|
INSERT INTO "users"("uid", "email", "name", "password", "flags")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: uid }},
|
||||||
|
${{ text: user.email }},
|
||||||
|
${{ text: user.name }},
|
||||||
|
${{ text: user.password }},
|
||||||
|
${{ jsonb: user.flags }}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return this.#getUserById(uid, tx);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(async (tx) => {
|
||||||
|
const updates = [];
|
||||||
|
if ("email" in user && user.email !== undefined)
|
||||||
|
updates.push(sql`"email" = ${{ text: user.email }}`);
|
||||||
|
if ("name" in user && user.name !== undefined)
|
||||||
|
updates.push(sql`"name" = ${{ text: user.name }}`);
|
||||||
|
if ("password" in user && user.password !== undefined) {
|
||||||
|
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
|
||||||
|
if (user.password.startsWith("#")) {
|
||||||
|
await tx.run(sql`
|
||||||
|
DELETE FROM "apikeys"
|
||||||
|
WHERE "uid" = ${{ uuid: user.uid }}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
await tx.run(sql`
|
||||||
|
DELETE FROM "sessions"
|
||||||
|
WHERE "uid" = ${{ uuid: user.uid }}
|
||||||
|
`);
|
||||||
|
updates.push(sql`"password" = ${{ text: user.password }}`);
|
||||||
|
}
|
||||||
|
if ("flags" in user && user.flags !== undefined)
|
||||||
|
updates.push(sql`"flags" = ${{ jsonb: user.flags as UserFlags }}`);
|
||||||
|
|
||||||
|
if (!updates.length) throw new InvalidAbodeError();
|
||||||
|
const { changes } = await tx.run(
|
||||||
|
sql`
|
||||||
|
UPDATE "users"
|
||||||
|
SET
|
||||||
|
"updated_at" = NOW(),
|
||||||
|
${joinSql(updates, sql`, `)}
|
||||||
|
WHERE "uid" = ${{ uuid: user.uid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
return this.#getUserById(user.uid, tx);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAbodes(): Promise<Abode[]> {
|
||||||
|
return selectAbodes(this.#db);
|
||||||
|
}
|
||||||
|
async #getAbodeById(aid: string, db: WrappedPgClient): Promise<Abode> {
|
||||||
|
const abode = await selectAbode(db, sql`a."aid" = ${{ uuid: aid }}`);
|
||||||
|
if (!abode) throw new NotFoundAbodeError();
|
||||||
|
return abode;
|
||||||
|
}
|
||||||
|
async getAbodeById(id: string): Promise<Abode> {
|
||||||
|
return this.#getAbodeById(id, this.#db);
|
||||||
|
}
|
||||||
|
async deleteAbodeById(id: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = await this.#db.run(
|
||||||
|
sql`
|
||||||
|
DELETE FROM "abodes"
|
||||||
|
WHERE "aid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const aid = crypto.randomUUID();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(async (tx) => {
|
||||||
|
await tx.run(
|
||||||
|
sql`
|
||||||
|
INSERT INTO "abodes"("aid", "name", "created_by", "updated_by")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: aid }},
|
||||||
|
${{ text: abode.name }},
|
||||||
|
${{ uuid: ctx.uid }},
|
||||||
|
${{ uuid: ctx.uid }}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return this.#getAbodeById(aid, tx);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const updates = calcUpdates({
|
||||||
|
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||||
|
})(abode);
|
||||||
|
if (!updates.length) throw new InvalidAbodeError();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(async (tx) => {
|
||||||
|
const { changes } = await tx.run(
|
||||||
|
sql`
|
||||||
|
UPDATE "abodes"
|
||||||
|
SET
|
||||||
|
"updated_at" = NOW(),
|
||||||
|
"updated_by" = ${{ uuid: ctx.uid }},
|
||||||
|
${joinSql(updates, sql`, `)}
|
||||||
|
WHERE "aid" = ${{ uuid: abode.aid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
return this.#getAbodeById(abode.aid, tx);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listResidents(): Promise<Resident[]> {
|
||||||
|
return selectResidents(this.#db);
|
||||||
|
}
|
||||||
|
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
||||||
|
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
||||||
|
}
|
||||||
|
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
||||||
|
return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
||||||
|
}
|
||||||
|
async #getResidentById(
|
||||||
|
uid: string,
|
||||||
|
aid: string,
|
||||||
|
db: WrappedPgClient
|
||||||
|
): Promise<Resident> {
|
||||||
|
const resident = await selectResident(
|
||||||
|
db,
|
||||||
|
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`
|
||||||
|
);
|
||||||
|
if (!resident) throw new NotFoundAbodeError();
|
||||||
|
return resident;
|
||||||
|
}
|
||||||
|
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
||||||
|
return this.#getResidentById(uid, aid, this.#db);
|
||||||
|
}
|
||||||
|
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = await this.#db.run(
|
||||||
|
sql`
|
||||||
|
DELETE FROM "residents"
|
||||||
|
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
async createResident(
|
||||||
|
resident: CreateResident,
|
||||||
|
ctx: { uid: string }
|
||||||
|
): Promise<Resident> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(async (tx) => {
|
||||||
|
await tx.run(
|
||||||
|
sql`
|
||||||
|
INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: resident.uid }},
|
||||||
|
${{ uuid: resident.aid }},
|
||||||
|
${{ jsonb: resident.flags }},
|
||||||
|
${{ uuid: ctx.uid }},
|
||||||
|
${{ uuid: ctx.uid }}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return this.#getResidentById(resident.uid, resident.aid, tx);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async updateResident(
|
||||||
|
resident: updateResident,
|
||||||
|
ctx: { uid: string }
|
||||||
|
): Promise<Resident> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const updates = calcUpdates({
|
||||||
|
flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
||||||
|
})(resident);
|
||||||
|
if (!updates.length) throw new InvalidAbodeError();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(async (tx) => {
|
||||||
|
const { changes } = await tx.run(
|
||||||
|
sql`
|
||||||
|
UPDATE "residents"
|
||||||
|
SET
|
||||||
|
"updated_at" = NOW(),
|
||||||
|
"updated_by" = ${{ uuid: ctx.uid }},
|
||||||
|
${joinSql(updates, sql`, `)}
|
||||||
|
WHERE
|
||||||
|
"uid" = ${{ uuid: resident.uid }}
|
||||||
|
AND "aid" = ${{ uuid: resident.aid }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
return this.#getResidentById(resident.uid, resident.aid, tx);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsersByAbodeId(id: string): Promise<ClientUser[]> {
|
||||||
|
return selectClientUsers(
|
||||||
|
this.#db,
|
||||||
|
sql`
|
||||||
|
JOIN "residents" r ON u."uid" = r."uid"
|
||||||
|
WHERE r."aid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
||||||
|
return selectAbodes(
|
||||||
|
this.#db,
|
||||||
|
sql`
|
||||||
|
JOIN "residents" r ON a."aid" = r."aid"
|
||||||
|
WHERE r."uid" = ${{ uuid: id }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
|
||||||
|
const session = await this.#db.get<{
|
||||||
|
uid: string;
|
||||||
|
expires_at: Date;
|
||||||
|
}>(sql`
|
||||||
|
SELECT "uid", "expires_at"
|
||||||
|
FROM "sessions"
|
||||||
|
WHERE "token" = ${{ text: token }}
|
||||||
|
`);
|
||||||
|
if (!session) throw new NotFoundAbodeError();
|
||||||
|
if (new Date(pgToDate(session.expires_at)).getTime() < Date.now()) {
|
||||||
|
if (!this.readonly) {
|
||||||
|
await this.#db.run(sql`
|
||||||
|
DELETE FROM "sessions"
|
||||||
|
WHERE "expires_at" < NOW()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
if (!this.readonly) {
|
||||||
|
await this.#db.run(sql`
|
||||||
|
UPDATE "sessions"
|
||||||
|
SET "expires_at" = NOW() + INTERVAL '7 days'
|
||||||
|
WHERE "token" = ${{ text: token }}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return this.#getUserById(session.uid, this.#db);
|
||||||
|
}
|
||||||
|
async createSession(uid: string): Promise<`as_${string}`> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const token = createSessionToken();
|
||||||
|
await this.#db.run(sql`
|
||||||
|
INSERT INTO "sessions"("uid", "token")
|
||||||
|
VALUES(${{ uuid: uid }}, ${{ text: token }})
|
||||||
|
`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
async deleteSessionsByUser(uid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
await this.#db.run(sql`
|
||||||
|
DELETE FROM "sessions"
|
||||||
|
WHERE "uid" = ${{ uuid: uid }}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getApikeyByToken(
|
||||||
|
token: `at_${string}`,
|
||||||
|
db: WrappedPgClient
|
||||||
|
): Promise<ClientApikey> {
|
||||||
|
const apikey = await selectClientApikey(
|
||||||
|
db,
|
||||||
|
sql`k."token" = ${{ text: token }}`
|
||||||
|
);
|
||||||
|
if (!apikey) throw new NotFoundAbodeError();
|
||||||
|
return apikey;
|
||||||
|
}
|
||||||
|
async getUserByApikey(
|
||||||
|
token: `at_${string}`
|
||||||
|
): Promise<[ClientUser, ClientApikey]> {
|
||||||
|
const apikey = await this.#getApikeyByToken(token, this.#db);
|
||||||
|
if (
|
||||||
|
apikey.expires_at &&
|
||||||
|
new Date(apikey.expires_at).getTime() < Date.now()
|
||||||
|
) {
|
||||||
|
throw new NotAuthorizedAbodeError();
|
||||||
|
}
|
||||||
|
return [await this.#getUserById(apikey.uid, this.#db), apikey];
|
||||||
|
}
|
||||||
|
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
||||||
|
return selectClientApikeys(this.#db, sql`k."uid" = ${{ uuid: uid }}`);
|
||||||
|
}
|
||||||
|
async getApikeyById(kid: string): Promise<ClientApikey> {
|
||||||
|
const apikey = await selectClientApikey(
|
||||||
|
this.#db,
|
||||||
|
sql`k."kid" = ${{ uuid: kid }}`
|
||||||
|
);
|
||||||
|
if (!apikey) throw new NotFoundAbodeError();
|
||||||
|
return apikey;
|
||||||
|
}
|
||||||
|
async createApikey(
|
||||||
|
apikey: CreateApikey
|
||||||
|
): Promise<[ClientApikey, `at_${string}`]> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const token = createApikeyToken();
|
||||||
|
const kid = crypto.randomUUID();
|
||||||
|
let expires = apikey.expires_at;
|
||||||
|
if (expires === undefined)
|
||||||
|
expires = new Date(
|
||||||
|
new Date().getTime() + 1000 * 60 * 60 * 24 * 365
|
||||||
|
).toISOString();
|
||||||
|
if (expires && new Date(expires).getTime() < Date.now())
|
||||||
|
throw new InvalidAbodeError();
|
||||||
|
|
||||||
|
await this.#db.run(sql`
|
||||||
|
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
||||||
|
VALUES(
|
||||||
|
${{ uuid: apikey.uid }},
|
||||||
|
${{ uuid: kid }},
|
||||||
|
${{ text: token }},
|
||||||
|
${{ text: apikey.name }},
|
||||||
|
${{ jsonb: apikey.permissions }},
|
||||||
|
${expires ? { date: expires } : { null: true }}
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
return [await this.#getApikeyByToken(token, this.#db), token];
|
||||||
|
}
|
||||||
|
async deleteApikeyById(kid: string): Promise<void> {
|
||||||
|
this.#checkReadonly();
|
||||||
|
const { changes } = await this.#db.run(sql`
|
||||||
|
DELETE FROM "apikeys"
|
||||||
|
WHERE "kid" = ${{ uuid: kid }}
|
||||||
|
`);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNotes(): Promise<PartialNote[]> {
|
||||||
|
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,126 @@
|
|||||||
|
import type {
|
||||||
|
AppliedMigration,
|
||||||
|
AvailableMigration,
|
||||||
|
Migrator,
|
||||||
|
} from "../types/Migrator.js";
|
||||||
|
import { init, migrations } from "./migrations/index.js";
|
||||||
|
import { pgToDate } from "./cast.js";
|
||||||
|
import { WrappedPool } from "./pool.js";
|
||||||
|
import { sql, toPositional } from "./sql.js";
|
||||||
|
|
||||||
|
export class PostgresMigrator implements Migrator {
|
||||||
|
#pool: WrappedPool;
|
||||||
|
|
||||||
|
constructor(pool: WrappedPool) {
|
||||||
|
this.#pool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #listAppliedMigrations(): Promise<
|
||||||
|
{ id: number; name: string; applied_at: string }[] | null
|
||||||
|
> {
|
||||||
|
const client = await this.#pool._pool.connect();
|
||||||
|
try {
|
||||||
|
const existsResult = await client.query<{ exists: boolean }>(
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = '_migrations'
|
||||||
|
) AS "exists"`
|
||||||
|
);
|
||||||
|
if (!existsResult.rows[0]?.exists) return null;
|
||||||
|
|
||||||
|
const result = await client.query<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
applied_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`
|
||||||
|
);
|
||||||
|
return result.rows.map((x) => ({
|
||||||
|
...x,
|
||||||
|
applied_at: pgToDate(x.applied_at),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAppliedMigrations(): Promise<AppliedMigration[]> {
|
||||||
|
return (await this.#listAppliedMigrations()) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
listAvailableMigrations(): AvailableMigration[] {
|
||||||
|
return migrations.map((m) => ({ id: m.id, name: m.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateTo(id: number): Promise<void> {
|
||||||
|
const target = migrations.find((x) => x.id === id);
|
||||||
|
if (!target) throw new Error(`No known migration with id ${id}`);
|
||||||
|
|
||||||
|
let current = await this.#listAppliedMigrations();
|
||||||
|
if (!current) {
|
||||||
|
const client = await this.#pool._pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query(init);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { id, name } of current) {
|
||||||
|
const migration = migrations.find((x) => x.id === id);
|
||||||
|
if (!migration)
|
||||||
|
throw new Error(`Applied migration ${id} (${name}) not known`);
|
||||||
|
if (migration.name !== name)
|
||||||
|
throw new Error(
|
||||||
|
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start =
|
||||||
|
migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
|
||||||
|
const end = migrations.indexOf(target) + 1;
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${target.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toApply = migrations.slice(start, end);
|
||||||
|
|
||||||
|
if (!toApply.length) {
|
||||||
|
console.log("Nothing to do");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const migration of toApply) {
|
||||||
|
console.log(`Applying migration ${migration.id} (${migration.name})`);
|
||||||
|
const client = await this.#pool._pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
for (const part of migration.parts) {
|
||||||
|
console.log(`- Applying part ${part.id} (${part.name})`);
|
||||||
|
if ("sql" in part) {
|
||||||
|
await client.query(part.sql);
|
||||||
|
} else {
|
||||||
|
await part.apply(this.#pool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recordSql = sql`
|
||||||
|
INSERT INTO "_migrations"("id", "name")
|
||||||
|
VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
|
||||||
|
`;
|
||||||
|
await client.query(toPositional(recordSql._sql), recordSql._vars);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done migrating database");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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 pgToDate(d: Date | string): string {
|
||||||
|
return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUserFlags: UserFlags = {};
|
||||||
|
export function pgToUserFlags(flags: unknown): UserFlags {
|
||||||
|
const out = { ...defaultUserFlags };
|
||||||
|
if (typeof flags !== "object" || !flags || Array.isArray(flags)) return out;
|
||||||
|
const f = flags as Record<string, unknown>;
|
||||||
|
if (f.admin === true) out.admin = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pgToPartialUser(user: {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
flags: unknown;
|
||||||
|
created_at: Date | string;
|
||||||
|
updated_at: Date | string;
|
||||||
|
}): PartialUser {
|
||||||
|
return {
|
||||||
|
uid: user.uid,
|
||||||
|
name: user.name,
|
||||||
|
flags: pgToUserFlags(user.flags),
|
||||||
|
created_at: pgToDate(user.created_at),
|
||||||
|
updated_at: pgToDate(user.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function pgToClientUser(user: {
|
||||||
|
uid: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
flags: unknown;
|
||||||
|
created_at: Date | string;
|
||||||
|
updated_at: Date | string;
|
||||||
|
}): ClientUser {
|
||||||
|
return {
|
||||||
|
...pgToPartialUser(user),
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pgToAbode(abode: {
|
||||||
|
aid: string;
|
||||||
|
name: string;
|
||||||
|
created_at: Date | string;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: Date | string;
|
||||||
|
updated_by: string | null;
|
||||||
|
}): Abode {
|
||||||
|
return {
|
||||||
|
aid: abode.aid,
|
||||||
|
name: abode.name,
|
||||||
|
created_at: pgToDate(abode.created_at),
|
||||||
|
created_by: abode.created_by,
|
||||||
|
updated_at: pgToDate(abode.updated_at),
|
||||||
|
updated_by: abode.updated_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultResidentFlags: ResidentFlags = {};
|
||||||
|
export function pgToResidentFlags(flags: unknown): ResidentFlags {
|
||||||
|
const out = { ...defaultResidentFlags };
|
||||||
|
if (typeof flags !== "object" || !flags || Array.isArray(flags)) return out;
|
||||||
|
const f = flags as Record<string, unknown>;
|
||||||
|
if (f.admin === true) out.admin = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pgToResident(resident: {
|
||||||
|
uid: string;
|
||||||
|
aid: string;
|
||||||
|
flags: unknown;
|
||||||
|
created_at: Date | string;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: Date | string;
|
||||||
|
updated_by: string | null;
|
||||||
|
}): Resident {
|
||||||
|
return {
|
||||||
|
uid: resident.uid,
|
||||||
|
aid: resident.aid,
|
||||||
|
flags: pgToResidentFlags(resident.flags),
|
||||||
|
created_at: pgToDate(resident.created_at),
|
||||||
|
created_by: resident.created_by,
|
||||||
|
updated_at: pgToDate(resident.updated_at),
|
||||||
|
updated_by: resident.updated_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultApikeyPermissions: ApikeyPermissions = {};
|
||||||
|
export function pgToApikeyPermissions(permissions: unknown): ApikeyPermissions {
|
||||||
|
const out = { ...defaultApikeyPermissions };
|
||||||
|
if (
|
||||||
|
typeof permissions !== "object" ||
|
||||||
|
!permissions ||
|
||||||
|
Array.isArray(permissions)
|
||||||
|
)
|
||||||
|
return out;
|
||||||
|
const p = permissions as Record<string, unknown>;
|
||||||
|
if (p.admin === true) out.admin = true;
|
||||||
|
if (p.all === true) out.all = true;
|
||||||
|
for (const key of ["users", "residents", "abodes"] as const) {
|
||||||
|
if (p[key] === "r" || p[key] === "rw") out[key] = p[key] as "r" | "rw";
|
||||||
|
}
|
||||||
|
for (const key of ["restrict_users", "restrict_abodes"] as const) {
|
||||||
|
if (
|
||||||
|
Array.isArray(p[key]) &&
|
||||||
|
(p[key] as unknown[]).every((x) => typeof x === "string")
|
||||||
|
) {
|
||||||
|
out[key] = p[key] as string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pgToClientApikey(apikey: {
|
||||||
|
uid: string;
|
||||||
|
kid: string;
|
||||||
|
name: string;
|
||||||
|
permissions: unknown;
|
||||||
|
created_at: Date | string;
|
||||||
|
expires_at: Date | string | null;
|
||||||
|
}): ClientApikey {
|
||||||
|
return {
|
||||||
|
uid: apikey.uid,
|
||||||
|
kid: apikey.kid,
|
||||||
|
name: apikey.name,
|
||||||
|
permissions: pgToApikeyPermissions(apikey.permissions),
|
||||||
|
created_at: pgToDate(apikey.created_at),
|
||||||
|
expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { GetDbStatic } from "../types/GetDb.js";
|
||||||
|
import { WrappedPool } from "./pool.js";
|
||||||
|
import { PostgresInterface } from "./PostgresInterface.js";
|
||||||
|
import { PostgresMigrator } from "./PostgresMigrator.js";
|
||||||
|
import { isPgUrl, parsePgUrl, pgProtocols } from "./url.js";
|
||||||
|
|
||||||
|
const getPgStatic: GetDbStatic = {
|
||||||
|
name: "postgres",
|
||||||
|
protocols: pgProtocols,
|
||||||
|
checkUrl: isPgUrl,
|
||||||
|
getDbInterface: async (url) => {
|
||||||
|
const { connectionString, readonly } = parsePgUrl(url);
|
||||||
|
return new PostgresInterface(new WrappedPool(connectionString, readonly));
|
||||||
|
},
|
||||||
|
getMigrator: async (url) => {
|
||||||
|
const { connectionString } = parsePgUrl(url);
|
||||||
|
return new PostgresMigrator(new WrappedPool(connectionString));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default getPgStatic;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "users" (
|
||||||
|
"uid" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL UNIQUE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL DEFAULT '#unset',
|
||||||
|
"flags" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE "abodes" (
|
||||||
|
"aid" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "residents" (
|
||||||
|
"uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||||
|
"aid" UUID NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE,
|
||||||
|
"flags" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY("uid", "aid")
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PgMigration } from "../types.js";
|
||||||
|
import p1 from "./1.users.pg.sql";
|
||||||
|
import p2 from "./2.abodes.pg.sql";
|
||||||
|
import p3 from "./3.residents.pg.sql";
|
||||||
|
|
||||||
|
export const m1: PgMigration = {
|
||||||
|
id: 1,
|
||||||
|
name: "init",
|
||||||
|
parts: [
|
||||||
|
{ id: 1, name: "users", sql: p1 },
|
||||||
|
{ id: 2, name: "abodes", sql: p2 },
|
||||||
|
{ id: 3, name: "residents", sql: p3 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||||
|
"token" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"expires_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "apikeys" (
|
||||||
|
"uid" UUID NOT NULL REFERENCES "users"("uid") ON DELETE CASCADE,
|
||||||
|
"kid" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"token" TEXT NOT NULL UNIQUE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"permissions" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"expires_at" TIMESTAMPTZ
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { PgMigration } from "../types.js";
|
||||||
|
import p1 from "./1.sessions.pg.sql";
|
||||||
|
import p2 from "./2.apikeys.pg.sql";
|
||||||
|
|
||||||
|
export const m2: PgMigration = {
|
||||||
|
id: 2,
|
||||||
|
name: "auth",
|
||||||
|
parts: [
|
||||||
|
{ id: 1, name: "sessions", sql: p1 },
|
||||||
|
{ id: 2, name: "apikeys", sql: p2 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "notes" (
|
||||||
|
"nid" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"aid" UUID NOT NULL REFERENCES "abodes"("aid") ON DELETE CASCADE,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL DEFAULT '',
|
||||||
|
"properties" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"created_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"updated_by" UUID REFERENCES "users"("uid") ON DELETE SET NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PgMigration } from "../types.js";
|
||||||
|
import p1 from "./1.notes.pg.sql";
|
||||||
|
|
||||||
|
export const m3: PgMigration = {
|
||||||
|
id: 3,
|
||||||
|
name: "notes",
|
||||||
|
parts: [{ id: 1, name: "notes", sql: p1 }],
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { m1 } from "./1.init/index.js";
|
||||||
|
import { m2 } from "./2.auth/index.js";
|
||||||
|
import { m3 } from "./3.notes/index.js";
|
||||||
|
import type { PgMigration } from "./types.js";
|
||||||
|
|
||||||
|
export { default as init } from "./init.pg.sql";
|
||||||
|
export const migrations: PgMigration[] = [m1, m2, m3];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE "_migrations" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { WrappedPgClient } from "../pool.js";
|
||||||
|
|
||||||
|
export type PgMigrationPart = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
apply: (client: WrappedPgClient) => Promise<void>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PgMigration = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parts: PgMigrationPart[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
import { ConflictAbodeError, NotFoundAbodeError } from "../types/errors.js";
|
||||||
|
import type { SqlCode } from "./sql.js";
|
||||||
|
import { toPositional } from "./sql.js";
|
||||||
|
|
||||||
|
export interface WrappedPgClient {
|
||||||
|
readonly: boolean;
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
all<R>(stmt: SqlCode): Promise<R[]>;
|
||||||
|
get<R>(stmt: SqlCode): Promise<R | null>;
|
||||||
|
run(stmt: SqlCode): Promise<{ changes: number }>;
|
||||||
|
multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R>;
|
||||||
|
rethrow<R>(fn: () => Promise<R>): Promise<R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rethrow<R>(fn: () => Promise<R>): Promise<R> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && "code" in e) {
|
||||||
|
switch ((e as NodeJS.ErrnoException).code) {
|
||||||
|
case "23505":
|
||||||
|
throw new ConflictAbodeError();
|
||||||
|
case "23503":
|
||||||
|
throw new NotFoundAbodeError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WrappedPgTx implements WrappedPgClient {
|
||||||
|
#client: pg.PoolClient;
|
||||||
|
#readonly: boolean;
|
||||||
|
|
||||||
|
constructor(client: pg.PoolClient, readonly_: boolean) {
|
||||||
|
this.#client = client;
|
||||||
|
this.#readonly = readonly_;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readonly(): boolean {
|
||||||
|
return this.#readonly;
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {}
|
||||||
|
|
||||||
|
async all<R>(stmt: SqlCode): Promise<R[]> {
|
||||||
|
const result = await this.#client.query(toPositional(stmt._sql), stmt._vars);
|
||||||
|
return result.rows as R[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<R>(stmt: SqlCode): Promise<R | null> {
|
||||||
|
const rows = await this.all<R>(stmt);
|
||||||
|
if (rows.length > 1) throw new Error("Multiple results");
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(stmt: SqlCode): Promise<{ changes: number }> {
|
||||||
|
const result = await this.#client.query(toPositional(stmt._sql), stmt._vars);
|
||||||
|
return { changes: result.rowCount ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
multi<R>(_fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
|
||||||
|
throw new Error("Nested transactions not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
rethrow = rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WrappedPool implements WrappedPgClient {
|
||||||
|
#pool: pg.Pool;
|
||||||
|
#readonly: boolean;
|
||||||
|
|
||||||
|
constructor(connectionStringOrPool: string | pg.Pool, readonly_ = false) {
|
||||||
|
this.#pool =
|
||||||
|
typeof connectionStringOrPool === "string"
|
||||||
|
? new pg.Pool({ connectionString: connectionStringOrPool })
|
||||||
|
: connectionStringOrPool;
|
||||||
|
this.#readonly = readonly_;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _pool(): pg.Pool {
|
||||||
|
return this.#pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readonly(): boolean {
|
||||||
|
return this.#readonly;
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
await this.#pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
async all<R>(stmt: SqlCode): Promise<R[]> {
|
||||||
|
const result = await this.#pool.query(toPositional(stmt._sql), stmt._vars);
|
||||||
|
return result.rows as R[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<R>(stmt: SqlCode): Promise<R | null> {
|
||||||
|
const rows = await this.all<R>(stmt);
|
||||||
|
if (rows.length > 1) throw new Error("Multiple results");
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(stmt: SqlCode): Promise<{ changes: number }> {
|
||||||
|
const result = await this.#pool.query(toPositional(stmt._sql), stmt._vars);
|
||||||
|
return { changes: result.rowCount ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
|
||||||
|
const client = await this.#pool.connect();
|
||||||
|
const tx = new WrappedPgTx(client, this.#readonly);
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const result = await fn(tx);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rethrow = rethrow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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 {
|
||||||
|
pgToAbode,
|
||||||
|
pgToClientApikey,
|
||||||
|
pgToClientUser,
|
||||||
|
pgToResident,
|
||||||
|
} from "./cast.js";
|
||||||
|
import type { WrappedPgClient } from "./pool.js";
|
||||||
|
import { sql, type SqlCode } from "./sql.js";
|
||||||
|
|
||||||
|
type RawClientUser = {
|
||||||
|
uid: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
flags: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
};
|
||||||
|
const sqlClientUser = sql`
|
||||||
|
SELECT u."uid", u."email", u."name", u."flags", u."created_at", u."updated_at"
|
||||||
|
FROM "users" u
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function selectClientUser(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where: SqlCode
|
||||||
|
): Promise<ClientUser | null> {
|
||||||
|
const raw = await db.get<RawClientUser>(
|
||||||
|
sql`${sqlClientUser} WHERE ${where}`
|
||||||
|
);
|
||||||
|
if (raw) return pgToClientUser(raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export async function selectClientUsers(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
rest?: SqlCode
|
||||||
|
): Promise<ClientUser[]> {
|
||||||
|
const rows = await db.all<RawClientUser>(
|
||||||
|
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser
|
||||||
|
);
|
||||||
|
return rows.map(pgToClientUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawAbode = {
|
||||||
|
aid: string;
|
||||||
|
name: string;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: Date;
|
||||||
|
updated_by: string | null;
|
||||||
|
};
|
||||||
|
const sqlAbode = sql`
|
||||||
|
SELECT a."aid", a."name", a."created_at", a."created_by", a."updated_at", a."updated_by"
|
||||||
|
FROM "abodes" a
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function selectAbode(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where: SqlCode
|
||||||
|
): Promise<Abode | null> {
|
||||||
|
const raw = await db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`);
|
||||||
|
if (raw) return pgToAbode(raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export async function selectAbodes(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
rest?: SqlCode
|
||||||
|
): Promise<Abode[]> {
|
||||||
|
const rows = await db.all<RawAbode>(
|
||||||
|
rest ? sql`${sqlAbode} ${rest}` : sqlAbode
|
||||||
|
);
|
||||||
|
return rows.map(pgToAbode);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawResident = {
|
||||||
|
uid: string;
|
||||||
|
aid: string;
|
||||||
|
flags: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: Date;
|
||||||
|
updated_by: string | null;
|
||||||
|
};
|
||||||
|
const sqlResident = sql`
|
||||||
|
SELECT "uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by"
|
||||||
|
FROM "residents"
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function selectResident(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where: SqlCode
|
||||||
|
): Promise<Resident | null> {
|
||||||
|
const raw = await db.get<RawResident>(sql`${sqlResident} WHERE ${where}`);
|
||||||
|
if (raw) return pgToResident(raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export async function selectResidents(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where?: SqlCode
|
||||||
|
): Promise<Resident[]> {
|
||||||
|
const rows = await db.all<RawResident>(
|
||||||
|
where ? sql`${sqlResident} WHERE ${where}` : sqlResident
|
||||||
|
);
|
||||||
|
return rows.map(pgToResident);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawClientApikey = {
|
||||||
|
uid: string;
|
||||||
|
kid: string;
|
||||||
|
name: string;
|
||||||
|
permissions: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
expires_at: Date | null;
|
||||||
|
};
|
||||||
|
const sqlClientApikey = sql`
|
||||||
|
SELECT k."uid", k."kid", k."name", k."permissions", k."created_at", k."expires_at"
|
||||||
|
FROM "apikeys" k
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function selectClientApikey(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where: SqlCode
|
||||||
|
): Promise<ClientApikey | null> {
|
||||||
|
const raw = await db.get<RawClientApikey>(
|
||||||
|
sql`${sqlClientApikey} WHERE ${where}`
|
||||||
|
);
|
||||||
|
if (raw) return pgToClientApikey(raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export async function selectClientApikeys(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where: SqlCode
|
||||||
|
): Promise<ClientApikey[]> {
|
||||||
|
const rows = await db.all<RawClientApikey>(
|
||||||
|
sql`${sqlClientApikey} WHERE ${where}`
|
||||||
|
);
|
||||||
|
return rows.map(pgToClientApikey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export type SqlVar = string | number;
|
||||||
|
export type SqlCode = { _sql: string; _vars: SqlVar[] };
|
||||||
|
type SqlArg =
|
||||||
|
| { uuid: string }
|
||||||
|
| { text: string }
|
||||||
|
| { jsonb: unknown }
|
||||||
|
| { date: string }
|
||||||
|
| { int: number }
|
||||||
|
| { null: true }
|
||||||
|
| SqlCode;
|
||||||
|
|
||||||
|
export function sql(text: TemplateStringsArray, ...args: SqlArg[]): SqlCode {
|
||||||
|
let code = "";
|
||||||
|
const vars: SqlVar[] = [];
|
||||||
|
for (const [i, part] of text.entries()) {
|
||||||
|
code += part;
|
||||||
|
if (i < args.length) {
|
||||||
|
const arg = args[i];
|
||||||
|
if ("uuid" in arg) {
|
||||||
|
code += "?";
|
||||||
|
vars.push(arg.uuid);
|
||||||
|
} else if ("text" in arg) {
|
||||||
|
code += "?";
|
||||||
|
vars.push(arg.text);
|
||||||
|
} else if ("jsonb" in arg) {
|
||||||
|
code += "?::jsonb";
|
||||||
|
vars.push(JSON.stringify(arg.jsonb));
|
||||||
|
} else if ("date" in arg) {
|
||||||
|
code += "?::timestamptz";
|
||||||
|
vars.push(arg.date);
|
||||||
|
} else if ("int" in arg) {
|
||||||
|
if (arg.int % 1) throw new Error("Not an integer");
|
||||||
|
code += "?";
|
||||||
|
vars.push(arg.int);
|
||||||
|
} else if ("null" in arg) {
|
||||||
|
code += "NULL";
|
||||||
|
} else {
|
||||||
|
code += arg._sql;
|
||||||
|
for (const v of arg._vars) vars.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { _sql: code, _vars: vars };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catSql(a: SqlCode, b: SqlCode): SqlCode {
|
||||||
|
return {
|
||||||
|
_sql: a._sql + b._sql,
|
||||||
|
_vars: [...a._vars, ...b._vars],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function joinSql(parts: SqlCode[], joiner: SqlCode): SqlCode {
|
||||||
|
return parts.reduce((a, b) => catSql(catSql(a, joiner), b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsafeSql(s: string): SqlCode {
|
||||||
|
return { _sql: s, _vars: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcUpdates<T extends object>(updater: {
|
||||||
|
[K in keyof T]: (value: NonNullable<T[K]>) => SqlCode;
|
||||||
|
}): (obj: Partial<T>) => SqlCode[] {
|
||||||
|
return (obj) => {
|
||||||
|
const updates: SqlCode[] = [];
|
||||||
|
for (const [prop, update] of Object.entries(updater)) {
|
||||||
|
if (prop in obj) {
|
||||||
|
updates.push(
|
||||||
|
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPositional(sql: string): string {
|
||||||
|
let i = 0;
|
||||||
|
return sql.replace(/\?/g, () => `$${++i}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const pgProtocols = ["postgres:", "postgresql:"];
|
||||||
|
|
||||||
|
export function isPgUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return pgProtocols.includes(urlObj.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePgUrl(url: string): { connectionString: string; readonly: boolean } {
|
||||||
|
if (!isPgUrl(url)) throw new Error("Not a postgres: URL");
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
|
||||||
|
urlObj.searchParams.delete("readonly");
|
||||||
|
return { connectionString: urlObj.toString(), readonly };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user