Skip to content

Commit

Permalink
- Migrated data layer from localStorage to PGLite
Browse files Browse the repository at this point in the history
- Implemented data migrations
- refactored Use Case classes to Interactor Classes
- Swapped inheritence model of Actor <-> Component
- Added database debuggin page
  • Loading branch information
mlhaufe committed Jun 23, 2024
1 parent 64a5fed commit 3cae26d
Show file tree
Hide file tree
Showing 216 changed files with 2,442 additions and 4,960 deletions.
13 changes: 5 additions & 8 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Node.js & Python",
"name": "Node.js",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/python:1": {}
},
"features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "npm install && npm install -g editorconfig",
"postCreateCommand": "npm install",
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
Expand All @@ -22,8 +21,6 @@
]
}
}
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
1 change: 0 additions & 1 deletion app.config.ts
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'
})
6 changes: 6 additions & 0 deletions app.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts" setup>
import MigrationManager from '~/data/MigrationManager'
useHead({
titleTemplate: (titleChunk) =>
titleChunk ? `${titleChunk} - Cathedral` : 'Cathedral'
Expand All @@ -14,6 +16,10 @@ const onAlphaDialogClose = () => {
// save the setting to local storage
localStorage.setItem('preAlphaDialogVisible', 'false');
};
const migrationManager = new MigrationManager(useAppConfig().connString);
await migrationManager.migrateToLatest();
</script>

<template>
Expand Down
4 changes: 4 additions & 0 deletions application/ComponentInteractor.ts
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> { }
21 changes: 17 additions & 4 deletions application/Interactor.ts
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)
}
}
3 changes: 0 additions & 3 deletions application/Mapper.ts
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;
}
56 changes: 39 additions & 17 deletions application/Repository.ts
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>;
}
6 changes: 0 additions & 6 deletions application/SlugRepository.ts

This file was deleted.

4 changes: 4 additions & 0 deletions application/UserStoryInteractor.ts
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> { }
6 changes: 6 additions & 0 deletions data/ComponentRepository.ts
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) }
}
14 changes: 14 additions & 0 deletions data/Migration.ts
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>
}
79 changes: 79 additions & 0 deletions data/MigrationManager.ts
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`)
}
}
84 changes: 82 additions & 2 deletions data/PGLiteRepository.ts
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 ')}` : ''
}
}
Loading

0 comments on commit 3cae26d

Please sign in to comment.