Move packages/apps back

This commit is contained in:
Darren Clarke 2023-03-10 08:26:51 +00:00
parent 6eaaf8e9be
commit 5535d6b575
348 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,30 @@
import { recordInfo } from "./record-info";
import { RepositoryBase } from "./base";
import { Flavor, UUID } from "../helpers";
import { UserId } from "./user";
export type AccountId = Flavor<UUID, "Account Id">;
export interface UnsavedAccount {
compoundId: string;
userId: UserId;
providerType: string;
providerId: string;
providerAccountId: string;
refreshToken: string;
accessToken: string;
accessTokenExpires: Date;
}
export interface SavedAccount extends UnsavedAccount {
id: AccountId;
createdAt: Date;
updatedAt: Date;
}
export const AccountRecord = recordInfo<UnsavedAccount, SavedAccount>(
"app_public",
"accounts"
);
export class AccountRecordRepository extends RepositoryBase(AccountRecord) {}

View file

@ -0,0 +1,57 @@
import { TableName } from "pg-promise";
import { IMain } from "../db/types";
import { CrudRepository } from "./crud-repository";
import { PgRecordInfo, UnsavedR, SavedR, KeyType } from "./record-info";
import type { IDatabase } from "pg-promise";
export type PgProtocol<T> = IDatabase<T> & T;
/**
* This function returns a constructor for a repository class for [[TRecordInfo]]
*
* @param aRecordType the record type runtime definition
*/
// haven't figured out a good return type for this function
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function unboundRepositoryBase<
TRecordInfo extends PgRecordInfo,
TDatabaseExtension
>(aRecordType: TRecordInfo) {
return class Repository extends CrudRepository<
UnsavedR<TRecordInfo>,
SavedR<TRecordInfo>,
KeyType<TRecordInfo>
> {
_recordType!: TRecordInfo;
static readonly recordType = aRecordType;
static readonly schemaName = aRecordType.schemaName;
static readonly tableName = aRecordType.tableName;
public readonly recordType = aRecordType;
public readonly schemaTable: TableName;
public db: PgProtocol<TDatabaseExtension>;
public pgp: IMain;
constructor(db: PgProtocol<TDatabaseExtension>) {
super();
this.pgp = db.$config.pgp;
this.schemaTable = new this.pgp.helpers.TableName({
schema: aRecordType.schemaName,
table: aRecordType.tableName,
});
this.db = db;
if (!this.db) {
throw new Error("Missing database in repository");
}
}
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function RepositoryBase<
Rec extends PgRecordInfo,
TDatabaseExtension = unknown
>(recordType: Rec) {
return unboundRepositoryBase<Rec, TDatabaseExtension>(recordType);
}

View file

@ -0,0 +1,321 @@
/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any */
import { TableName } from "pg-promise";
import decamelcaseKeys from "decamelcase-keys";
import isObject from "lodash/isObject";
import isArray from "lodash/isArray";
import zipObject from "lodash/zipObject";
import isEmpty from "lodash/isEmpty";
import omit from "lodash/omit";
import { IDatabase, IMain, IResult } from "../db/types";
import { PgRecordInfo, idKeysOf } from "./record-info";
export interface ICrudRepository<
TUnsavedR,
TSavedR extends TUnsavedR & IdKeyT,
IdKeyT extends object
> {
findById(id: IdKeyT): Promise<TSavedR | null>;
findBy(example: Partial<TSavedR>): Promise<TSavedR | null>;
findAll(): Promise<TSavedR[]>;
findAllBy(example: Partial<TSavedR>): Promise<TSavedR[]>;
existsById(id: IdKeyT): Promise<boolean>;
countBy(example: Partial<TSavedR>): Promise<number>;
count(): Promise<number>;
insert(record: TUnsavedR): Promise<TSavedR>;
insertAll(toInsert: TUnsavedR[]): Promise<TSavedR[]>;
updateById(id: IdKeyT, attrs: Partial<TSavedR>): Promise<TSavedR>;
update(record: TSavedR): Promise<TSavedR>;
updateAll(toUpdate: TSavedR[]): Promise<TSavedR[]>;
remove(record: TSavedR): Promise<number>;
removeAll(toRemove: TSavedR[]): Promise<number>;
removeBy(example: Partial<TSavedR>): Promise<TSavedR | null>;
removeById(id: IdKeyT): Promise<number>;
}
// The snake cased object going into the db
type DatabaseRow = Record<string, unknown>;
/**
* Base class for generic CRUD operations on a repository for a specific type.
*
* Several assumptions are made about your environment for this generic CRUD repository to work:
*
* - the underlying column names are snake_cased (this behavior can be changed, see [[columnize]])
* - the rows have only a single primary key (composite keys are not supported)
*
* @typeParam ID The type of the id column
* @typeParam T The type of the record
*/
export abstract class CrudRepository<
TUnsavedR,
TSavedR extends TUnsavedR & IdKeyT,
IdKeyT extends object
> implements ICrudRepository<TUnsavedR, TSavedR, IdKeyT>
{
/**
* the fully qualified table name
*/
abstract schemaTable: TableName;
abstract recordType: PgRecordInfo<TUnsavedR, TSavedR, IdKeyT>;
abstract db: IDatabase;
abstract pgp: IMain;
/**
* Converts the record's columns into snake_case
*
* @param record the record of type T to convert
*/
columnize(record: TSavedR | Partial<TSavedR>): DatabaseRow {
return decamelcaseKeys(record);
}
/*
* Creates a simple where clause with each key-value in `example` is
* formatted as KEY=VALUE and all kv-pairs are ANDed together.
*
* @param example key value pair of column names and values
*/
where(example: Partial<TSavedR>): string {
const snaked = this.columnize(example);
const clauses = Object.keys(snaked).reduce((acc, cur) => {
const colName = this.pgp.as.format("$1:name", cur);
return `${acc} and ${colName} = $<${cur}>`;
}, "");
const where = this.pgp.as.format(`WHERE 1=1 ${clauses}`, { ...snaked }); // Pre-format WHERE condition
return where;
}
/**
* Converts a value containing the id of the record (which could be a primitive type, a composite object, or an array of values)
* into an object which can be safely passed to [[where]].
*/
idsObj(idValues: IdKeyT): IdKeyT {
if (isEmpty(idValues)) {
throw new Error(`idsObj(${this.schemaTable}): passed empty id(s)`);
}
let ids = {};
const idKeys = idKeysOf(this.recordType as any);
if (isArray(idValues)) {
ids = zipObject(idKeys, idValues);
} else if (isObject(idValues)) {
ids = idValues;
} else {
if (idKeys.length !== 1) {
throw new Error(
`idsObj(${this.schemaTable}): passed record has multiple primary keys. the ids must be passed as an object or array. ${idValues}`
);
}
// @ts-ignore
ids[idKeys[0]] = idValues;
}
// this is a sanity check so we don't do something like
// deleting all the data if a WHERE slips in with no ids
if (isEmpty(ids)) {
throw new Error(`idsObj(${this.schemaTable}): passed empty ids`);
}
return ids as IdKeyT;
}
/**
* Returns all rows in the table
*/
async findAll(): Promise<TSavedR[]> {
return this.db.any("SELECT * FROM $1", [this.schemaTable]);
}
/**
* Returns the number of rows in the table
*/
async count(): Promise<number> {
return this.db.one(
"SELECT count(*) FROM $1",
[this.schemaTable],
(a: { count: string }) => Number(a.count)
);
}
/**
* Returns the number of rows in the table matching the example
*/
async countBy(example: Partial<TSavedR>): Promise<number> {
return this.db.one(
"SELECT count(*) FROM $1 $2:raw ",
[this.schemaTable, this.where(example)],
(a: { count: string }) => Number(a.count)
);
}
/**
* Find a single row where the example are true.
* @param example key-value pairs of column names and values
*/
async findBy(example: Partial<TSavedR>): Promise<TSavedR | null> {
return this.db.oneOrNone("SELECT * FROM $1 $2:raw LIMIT 1", [
this.schemaTable,
this.where(example),
]);
}
/**
* Retrieves a row by ID
* @param id
*/
async findById(id: IdKeyT): Promise<TSavedR | null> {
const where = this.idsObj(id);
return this.db.oneOrNone("SELECT * FROM $1 $2:raw", [
this.schemaTable,
this.where(where),
]);
}
/**
* Returns whether a given row with id exists
* @param id
*/
async existsById(id: IdKeyT): Promise<boolean> {
return this.db.one(
"SELECT EXISTS(SELECT 1 FROM $1 $2:raw)",
[this.schemaTable, this.where(this.idsObj(id))],
(a: { exists: boolean }) => a.exists
);
}
/**
* Find all rows where the example are true.
* @param example key-value pairs of column names and values
*/
async findAllBy(example: Partial<TSavedR>): Promise<TSavedR[]> {
return this.db.any("SELECT * FROM $1 $2:raw", [
this.schemaTable,
this.where(example),
]);
}
/**
* Creates a new row
* @param record
* @return the new row
*/
async insert(record: TUnsavedR): Promise<TSavedR> {
return this.db.one("INSERT INTO $1 ($2:name) VALUES ($2:csv) RETURNING *", [
this.schemaTable,
this.columnize(record as any),
]);
}
/**
* Like `insert` but will insert/update a batch of rows at once
*/
async insertAll(toInsert: TUnsavedR[]): Promise<TSavedR[]> {
return this.db.tx((t) => {
const insertCommands: any[] = [];
toInsert.forEach((record) => {
insertCommands.push(this.insert(record));
});
return t.batch(insertCommands);
});
}
/**
* Deletes a row by id
* @param id
* @return the number of rows affected
*/
async removeById(id: IdKeyT): Promise<number> {
return this.db.result(
"DELETE FROM $1 $2:raw",
[this.schemaTable, this.where(this.idsObj(id))],
(r: IResult) => r.rowCount
);
}
/**
* Delete records matching the query
* @param example key-value pairs of column names and values
*/
async removeBy(example: Partial<TSavedR>): Promise<TSavedR | null> {
if (isEmpty(example))
throw new Error(
`removeBy(${this.schemaTable}): passed empty constraint!`
);
return this.db.result("DELETE FROM $1 $2:raw", [
this.schemaTable,
this.where(example),
]);
}
/**
* Deletes the given row
*
* @param record to remove
* @return the number of rows affected
*/
async remove(record: TSavedR): Promise<number> {
return this.removeById(this.recordType.idOf(record));
}
/**
* Deletes all rows
* @param toRemove a list of rows to remove, if empty, DELETES ALL ROWS
* @return the number of rows affected
*/
async removeAll(toRemove: TSavedR[] = []): Promise<number> {
if (toRemove.length === 0) {
return this.db.result(
"DELETE FROM $1 WHERE 1=1;",
[this.schemaTable],
(r: IResult) => r.rowCount
);
}
const results = await this.db.tx((t) => {
const delCommands: any[] = [];
toRemove.forEach((record) => {
delCommands.push(this.remove(record));
});
return t.batch(delCommands);
});
return results.length;
}
/**
* Updates an existing row
* @param id
* @param attrs
* @return the updated row
*/
async updateById(id: IdKeyT, attrs: Partial<TSavedR>): Promise<TSavedR> {
const idKeys = idKeysOf(this.recordType as any);
const attrsSafe = omit(attrs, idKeys);
return this.db.one(
"UPDATE $1 SET ($2:name) = ROW($2:csv) $3:raw RETURNING *",
[this.schemaTable, this.columnize(attrsSafe), this.where(this.idsObj(id))]
);
}
async update(record: TSavedR): Promise<TSavedR> {
return this.updateById(this.recordType.idOf(record), record);
}
/**
* Update a batch of records at once
*/
async updateAll(toUpdate: TSavedR[]): Promise<TSavedR[]> {
return this.db.tx((t) => {
const updateCommands: any[] = [];
toUpdate.forEach((record) => {
updateCommands.push(this.update(record));
});
return t.batch(updateCommands);
});
}
}

View file

@ -0,0 +1,16 @@
export * from "./base";
export * from "./record-info";
export * from "./crud-repository";
export * from "./user";
export * from "./session";
export * from "./account";
import type { AccountRecordRepository } from "./account";
import type { UserRecordRepository } from "./user";
import type { SessionRecordRepository } from "./session";
export interface IMetamigoRepositories {
users: UserRecordRepository;
sessions: SessionRecordRepository;
accounts: AccountRecordRepository;
}

View file

@ -0,0 +1,54 @@
export interface EntityType<TUnsaved = any, TSaved = any, TIds extends object = any> {
_saved: TSaved;
_unsaved: TUnsaved;
_idKeys: TIds;
idOf: (rec: TSaved) => TIds;
}
export declare type UnsavedR<T extends {
_unsaved: any;
}> = T["_unsaved"];
export declare type SavedR<T extends {
_saved: any;
}> = T["_saved"];
export declare type KeyType<R extends EntityType> = R["_idKeys"];
export interface PgRecordInfo<Unsaved = any, Saved extends Unsaved & IdType = any, IdType extends object = any> extends EntityType<Unsaved, Saved, IdType> {
tableName: string;
schemaName: string;
idKeys: (keyof Saved)[];
}
/**
* Extract the runtime key name from a recordInfo
*/
export declare function idKeysOf<RI extends PgRecordInfo>(recordInfoWithIdKey: RI): string[];
/**
* Turns a record type with possibly more fields than "id" into an array
*/
export declare function collectIdValues<RecordT extends PgRecordInfo>(idObj: KeyType<RecordT>, knexRecordType: RecordT): string[];
/**
*
* Creates a record descriptor that captures the table name, primary key name,
* unsaved type, and saved type of a database record type. Assumes "id" as the
* primary key name
*
*/
export declare function recordInfo<Unsaved, Saved extends Unsaved & {
id: any;
}>(schemaName: string, tableName: string): PgRecordInfo<Unsaved, Saved, Pick<Saved, "id">>;
export declare function recordInfo<Type extends {
id: string;
}>(schemaName: string, tableName: string): PgRecordInfo<Type, Type, Pick<Type, "id">>;
/**
*
* Creates a record descriptor that captures the table name, primary key name,
* unsaved type, and saved type of a database record type.
*
*/
export declare function recordInfo<Unsaved, Saved extends Unsaved, Id extends keyof Saved>(schemaName: string, tableName: string, idKey: Id[]): PgRecordInfo<Unsaved, Saved, Pick<Saved, Id>>;
/**
*
* Creates a record descriptor for records with composite primary keys
*
*/
export declare function compositeRecordType<TUnsaved, TSaved extends TUnsaved = TUnsaved>(schemaName: string, tableName: string): {
withCompositeKeys<TKeys extends keyof TSaved>(keys: TKeys[]): PgRecordInfo<TUnsaved, TSaved, Pick<TSaved, TKeys>>;
};

View file

@ -0,0 +1,133 @@
/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types */
import at from "lodash/at";
import pick from "lodash/pick";
export interface EntityType<
TUnsaved = any,
TSaved = any,
TIds extends object = any
> {
_saved: TSaved;
_unsaved: TUnsaved;
_idKeys: TIds;
idOf: (rec: TSaved) => TIds;
}
export type UnsavedR<T extends { _unsaved: any }> = T["_unsaved"];
export type SavedR<T extends { _saved: any }> = T["_saved"];
export type KeyType<R extends EntityType> = R["_idKeys"];
export interface PgRecordInfo<
Unsaved = any,
Saved extends Unsaved & IdType = any,
IdType extends object = any
> extends EntityType<Unsaved, Saved, IdType> {
tableName: string;
schemaName: string;
idKeys: (keyof Saved)[];
}
/**
* Extract the runtime key name from a recordInfo
*/
export function idKeysOf<RI extends PgRecordInfo>(
recordInfoWithIdKey: RI
): string[] {
return recordInfoWithIdKey.idKeys as any;
}
/**
* Turns a record type with possibly more fields than "id" into an array
*/
export function collectIdValues<RecordT extends PgRecordInfo>(
idObj: KeyType<RecordT>,
knexRecordType: RecordT
): string[] {
return at(idObj, idKeysOf(knexRecordType));
}
function castToRecordInfo(
runtimeData: Omit<PgRecordInfo, "_idKeys" | "_saved" | "_unsaved">
): PgRecordInfo {
return runtimeData as PgRecordInfo;
}
/**
*
* Creates a record descriptor that captures the table name, primary key name,
* unsaved type, and saved type of a database record type. Assumes "id" as the
* primary key name
*
*/
export function recordInfo<Unsaved, Saved extends Unsaved & { id: any }>(
schemaName: string,
tableName: string
): PgRecordInfo<Unsaved, Saved, Pick<Saved, "id">>;
export function recordInfo<Type extends { id: string }>(
schemaName: string,
tableName: string
): PgRecordInfo<Type, Type, Pick<Type, "id">>;
/**
*
* Creates a record descriptor that captures the table name, primary key name,
* unsaved type, and saved type of a database record type.
*
*/
export function recordInfo<
Unsaved,
Saved extends Unsaved,
Id extends keyof Saved
>(
schemaName: string,
tableName: string,
idKey: Id[]
): PgRecordInfo<Unsaved, Saved, Pick<Saved, Id>>;
/**
*
* Don't use this signature be sure to provide unsaved and saved types.
*
*/
export function recordInfo(
schemaName: string,
tableName: string,
idKeys?: string[]
) {
idKeys = idKeys || ["id"];
return castToRecordInfo({
schemaName,
tableName,
idKeys,
idOf: (rec) => pick(rec, idKeys as any),
});
}
/**
*
* Creates a record descriptor for records with composite primary keys
*
*/
export function compositeRecordType<
TUnsaved,
TSaved extends TUnsaved = TUnsaved
>(
schemaName: string,
tableName: string
): {
withCompositeKeys<TKeys extends keyof TSaved>(
keys: TKeys[]
): PgRecordInfo<TUnsaved, TSaved, Pick<TSaved, TKeys>>;
} {
return {
withCompositeKeys(keys) {
return castToRecordInfo({
schemaName,
tableName,
idKeys: keys,
idOf: (rec) => pick(rec, keys),
});
},
};
}

View file

@ -0,0 +1,26 @@
import { recordInfo } from "./record-info";
import { RepositoryBase } from "./base";
import { Flavor, UUID } from "../helpers";
import { UserId } from "./user";
export type SessionId = Flavor<UUID, "Session Id">;
export interface UnsavedSession {
userId: UserId;
expires: Date;
sessionToken: string;
accessToken: string;
}
export interface SavedSession extends UnsavedSession {
id: SessionId;
createdAt: Date;
updatedAt: Date;
}
export const SessionRecord = recordInfo<UnsavedSession, SavedSession>(
"app_private",
"sessions"
);
export class SessionRecordRepository extends RepositoryBase(SessionRecord) {}

View file

@ -0,0 +1,40 @@
import { recordInfo } from "./record-info";
import { RepositoryBase } from "./base";
import { Flavor, UUID } from "../helpers";
export type UserId = Flavor<UUID, "User Id">;
export interface UnsavedUser {
name: string;
email: string;
emailVerified: Date;
avatar: string;
isActive: boolean;
userRole: string;
}
export interface SavedUser extends UnsavedUser {
id: UserId;
createdAt: Date;
updatedAt: Date;
}
export const UserRecord = recordInfo<UnsavedUser, SavedUser>(
"app_public",
"users"
);
export class UserRecordRepository extends RepositoryBase(UserRecord) {
async upsert(record: UnsavedUser | SavedUser): Promise<SavedUser> {
return this.db.one(
`INSERT INTO $1 ($2:name) VALUES ($2:csv)
ON CONFLICT (email)
DO UPDATE SET
name = EXCLUDED.name,
avatar = EXCLUDED.avatar,
email_verified = EXCLUDED.email_verified
RETURNING *`,
[this.schemaTable, this.columnize(record)]
);
}
}