-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Migrated data layer from localStorage to PGLite
- Implemented data migrations - refactored Use Case classes to Interactor Classes - Swapped inheritence model of Actor <-> Component - Added database debuggin page
- Loading branch information
Showing
216 changed files
with
2,442 additions
and
4,960 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,4 @@ | ||
export default defineAppConfig({ | ||
darkMode: 'light', | ||
serializationVersion: '0.5.0', | ||
connString: 'idb://cathedral' | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
import Interactor from "~/application/Interactor"; | ||
import type Component from "~/domain/Component"; | ||
|
||
export default class ComponentInteractor extends Interactor<Component> { } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,30 @@ | ||
import type Entity from "~/domain/Entity"; | ||
import type Repository from "./Repository"; | ||
import type { Uuid } from "~/domain/Uuid"; | ||
import type { Properties } from "~/domain/Properties"; | ||
|
||
export default abstract class Interactor<E extends Entity> { | ||
constructor( | ||
readonly repository: Repository<E> | ||
) { } | ||
|
||
abstract create(item: Omit<E, 'id'>): Promise<Uuid> | ||
create(item: Omit<Properties<E>, 'id'>): Promise<Uuid> { | ||
return this.repository.create(item) | ||
} | ||
|
||
abstract delete(id: Uuid): Promise<void> | ||
delete(id: Uuid): Promise<void> { | ||
return this.repository.delete(id) | ||
} | ||
|
||
abstract getAll(parentId: Uuid): Promise<E[]> | ||
get(id: Uuid): Promise<E | undefined> { | ||
return this.repository.get(id) | ||
} | ||
|
||
abstract update(item: Pick<E, 'id'>): Promise<void> | ||
getAll(criteria?: Partial<Properties<E>>): Promise<E[]> { | ||
return this.repository.getAll(criteria) | ||
} | ||
|
||
update(item: Properties<E>): Promise<void> { | ||
return this.repository.update(item) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,4 @@ | ||
import type { SemVerString } from "~/domain/SemVer"; | ||
|
||
export default abstract class Mapper<Source, Target> { | ||
constructor(readonly serializationVersion: SemVerString) { } | ||
abstract mapTo(source: Source): Target; | ||
abstract mapFrom(target: Target): Source; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,26 +1,48 @@ | ||
import type Entity from '~/domain/Entity'; | ||
import type Mapper from './Mapper.js'; | ||
import type { Properties } from '~/domain/Properties.js'; | ||
import type { Uuid } from '~/domain/Uuid.js'; | ||
import { useAppConfig } from '#app' | ||
import { PGlite } from "@electric-sql/pglite"; | ||
|
||
/** | ||
* A repository for entities. | ||
*/ | ||
export default abstract class Repository<E extends Entity> { | ||
private _mapper: Mapper<E, any>; | ||
|
||
constructor({ mapper }: Properties<Repository<E>>) { | ||
this._mapper = mapper; | ||
} | ||
|
||
get mapper(): Mapper<E, any> { return this._mapper; } | ||
|
||
abstract getAll(filter?: (entity: E) => boolean): Promise<E[]>; | ||
/** | ||
* The connection to the database. | ||
*/ | ||
static conn = new PGlite(useAppConfig().connString) | ||
|
||
/** | ||
* Creates an item to the repository. | ||
* @param item The properties of the item to create. | ||
* @returns The id of the added item. | ||
*/ | ||
abstract create(item: Omit<Properties<E>, 'id'>): Promise<Uuid> | ||
|
||
/** | ||
* Deletes an item from the repository. | ||
* @param id The id of the item to delete. | ||
*/ | ||
abstract delete(id: E['id']): Promise<void>; | ||
|
||
/** | ||
* Gets an item from the repository. | ||
* @param id The id of the item to get. | ||
* @returns The item with the given id, or undefined if it does not exist. | ||
*/ | ||
abstract get(id: E['id']): Promise<E | undefined>; | ||
|
||
abstract add(item: E): Promise<Uuid> | ||
|
||
abstract update(item: E): Promise<void>; | ||
|
||
abstract delete(id: E['id']): Promise<void>; | ||
|
||
abstract clear(): Promise<void>; | ||
/** | ||
* Gets all items in the repository. | ||
* @param criteria The criteria to filter the items by. | ||
* @returns All items in the repository. | ||
*/ | ||
abstract getAll(criteria?: Partial<Properties<E>>): Promise<E[]>; | ||
|
||
/** | ||
* Updates an item in the repository. | ||
* @param item The item to update. | ||
*/ | ||
abstract update(item: Properties<E>): Promise<void>; | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
import Interactor from "~/application/Interactor"; | ||
import UserStory from "../domain/UserStory"; | ||
|
||
export default class UserStoryInteractor extends Interactor<UserStory> { } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import Component from "~/domain/Component"; | ||
import PGLiteRepository from "./PGLiteRepository"; | ||
|
||
export default class ComponentRepository extends PGLiteRepository<Component> { | ||
constructor() { super('cathedral.component', Component) } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { type PGliteInterface } from "@electric-sql/pglite"; | ||
/** | ||
* Represents a migration that can be run on the database. | ||
*/ | ||
export default abstract class Migration { | ||
/** | ||
* Applies the migration to the database. | ||
*/ | ||
abstract up(db: PGliteInterface): Promise<void> | ||
/** | ||
* Reverts the migration from the database. | ||
*/ | ||
abstract down(db: PGliteInterface): Promise<void> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { PGlite } from "@electric-sql/pglite"; | ||
import Migration from "./Migration"; | ||
import InitDatabase_00001 from "./migrations/00001-InitDatabase"; | ||
|
||
type MigrationName = `${number}-${string}` | ||
|
||
/** | ||
* Manages the migration of a database | ||
*/ | ||
export default class MigrationManager { | ||
private _migrations = new Map<MigrationName, typeof Migration>([ | ||
["00001-InitDatabase", InitDatabase_00001] | ||
]) | ||
|
||
constructor( | ||
/** | ||
* The connection string for the database | ||
*/ | ||
private _connString: string | ||
) { } | ||
|
||
/** | ||
* Gets the names of the migrations that have been executed | ||
* @param db The database to get the executed migration names from | ||
* @returns The names of the executed migrations | ||
*/ | ||
private async _getExecutedMigrationNames(db: PGlite): Promise<MigrationName[]> { | ||
let results; | ||
try { | ||
results = await db.query( | ||
`SELECT name FROM cathedral.__migration_history` | ||
); | ||
} catch (error) { | ||
results = { rows: [] }; | ||
} | ||
|
||
return results.rows.map((row: any) => row.name); | ||
} | ||
|
||
/** | ||
* Returns the migrations that have not been executed | ||
* @param executedMigrationNames The names of the migrations that have been executed | ||
* @returns The names of the pending migrations | ||
*/ | ||
private async _getPendingMigrations(executedMigrationNames: MigrationName[]): Promise<Map<MigrationName, Migration>> { | ||
const pendingMigrations = new Map<MigrationName, Migration>() | ||
|
||
for (const [migrationName, MigrationCons] of this._migrations) { | ||
if (!executedMigrationNames.includes(migrationName)) | ||
pendingMigrations.set(migrationName, new (MigrationCons as any)()) | ||
} | ||
|
||
return pendingMigrations | ||
} | ||
|
||
private async _addMigrationNameToDb(db: PGlite, migrationName: MigrationName) { | ||
return db.query( | ||
`INSERT INTO cathedral.__migration_history (Name) VALUES ($1)`, | ||
[migrationName] | ||
) | ||
} | ||
|
||
/** | ||
* Migrates the database to the latest version | ||
*/ | ||
async migrateToLatest() { | ||
console.log(`Migrating database to latest version...`) | ||
const db = new PGlite(this._connString) | ||
|
||
const executedMigrationNames = await this._getExecutedMigrationNames(db), | ||
pendingMigrations = await this._getPendingMigrations(executedMigrationNames) | ||
|
||
for (const [migrationName, migration] of pendingMigrations) { | ||
await migration.up(db) | ||
await this._addMigrationNameToDb(db, migrationName) | ||
} | ||
console.log(`Database migrated to latest version`) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,87 @@ | ||
import Repository from '~/application/Repository'; | ||
import type Entity from '~/domain/Entity'; | ||
import { PGliteWorker } from "@electric-sql/pglite/worker"; | ||
import type { Properties } from '~/domain/Properties'; | ||
import type { Uuid } from '~/domain/Uuid'; | ||
|
||
const reCamelCaseToSnakeCase = (str: string) => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) | ||
|
||
const reSnakeCaseToCamelCase = (str: string) => str.replace(/_./g, match => match[1].toUpperCase()) | ||
|
||
type Constructor<T> = { new(...args: any[]): T } | ||
|
||
export default abstract class PGLiteRepository<E extends Entity> extends Repository<E> { | ||
private static _db = new PGliteWorker('idb://cathedral'); | ||
constructor( | ||
private readonly _tableName: string, | ||
private readonly _Constructor: Constructor<E> | ||
) { super() } | ||
|
||
async create(item: Omit<Properties<E>, 'id'>): Promise<Uuid> { | ||
const conn = PGLiteRepository.conn | ||
|
||
const sql = ` | ||
INSERT INTO ${this._tableName} (${Object.keys(item).map(reCamelCaseToSnakeCase).join(', ')}) | ||
VALUES (${Object.keys(item).map((_, index) => `$${index + 1}`).join(', ')}) | ||
RETURNING id | ||
` | ||
|
||
const result = (await conn.query<{ id: Uuid }>( | ||
sql, Object.values(item) | ||
)).rows[0] | ||
|
||
return result.id | ||
} | ||
|
||
async get(id: Uuid): Promise<E | undefined> { | ||
return (await this.getAll({ id } as Record<keyof E, any>))[0] | ||
} | ||
|
||
async getAll(criteria: Partial<Properties<E>> = {}): Promise<E[]> { | ||
const conn = PGLiteRepository.conn | ||
|
||
const sql = ` | ||
SELECT * FROM ${this._tableName} | ||
${this._criteriaToSql(criteria)} | ||
` | ||
|
||
const results = (await conn.query<E>( | ||
sql, Object.values(criteria ?? {}) | ||
)).rows | ||
|
||
return results.map(result => new this._Constructor( | ||
Object.fromEntries( | ||
Object.entries(result).map(([key, value]) => [reSnakeCaseToCamelCase(key), value]) | ||
) | ||
)) | ||
} | ||
|
||
async delete(id: Uuid): Promise<void> { | ||
const conn = PGLiteRepository.conn | ||
|
||
const sql = ` | ||
DELETE FROM ${this._tableName} | ||
WHERE id = $1 | ||
` | ||
|
||
await conn.query(sql, [id]) | ||
} | ||
|
||
async update(item: Properties<E>): Promise<void> { | ||
const conn = PGLiteRepository.conn | ||
|
||
const sql = ` | ||
UPDATE ${this._tableName} | ||
SET ${Object.keys(item).map((key, index) => `${reCamelCaseToSnakeCase(key)} = $${index + 1}`).join(', ')} | ||
WHERE id = $${Object.keys(item).length + 1} | ||
` | ||
|
||
await conn.query(sql, [...Object.values(item), (item as any).id]) | ||
} | ||
|
||
protected _criteriaToSql(criteria: Record<string, any>): string { | ||
const conditions = Object.entries(criteria).map(([key], index) => { | ||
return `${reCamelCaseToSnakeCase(key)} = $${index + 1}` | ||
}) | ||
|
||
return conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '' | ||
} | ||
} |
Oops, something went wrong.