Template
1
0

feat: initial boilerplate

This commit is contained in:
2025-08-11 20:45:41 +02:00
parent d98524254f
commit 1215a98afc
148 changed files with 6935 additions and 2060 deletions

View File

@@ -0,0 +1,268 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { Avatar, Contact, Email, Name, Phone, Strategy } from "relay/schemas";
import { db, toAccountDriver } from "~libraries/read-store/mod.ts";
import { eventStore } from "../event-store.ts";
import { AccountCreatedData } from "../events/account.ts";
import { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
export class Account extends AggregateRoot<EventStoreFactory> {
static override readonly name = "account";
id!: string;
organizationId?: string;
type!: "admin" | "consultant" | "organization";
avatar?: Avatar;
name?: Name;
contact: Contact = {
emails: [],
phones: [],
};
strategies: Strategy[] = [];
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Account);
static create(data: AccountCreatedData, meta: Auditor): Account {
return new Account().push({
type: "account:created",
data,
meta,
});
}
static async getById(stream: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", stream, reducer: this.#reducer });
}
static async getByEmail(email: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", relation: Account.emailRelation(email), reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Relations
// -------------------------------------------------------------------------
static emailRelation(email: string): `account:email:${string}` {
return `account:email:${email}`;
}
static passwordRelation(alias: string): `account:password:${string}` {
return `account:password:${alias}`;
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "account:created": {
this.id = event.stream;
this.organizationId = event.data.type === "organization" ? event.data.organizationId : undefined;
this.type = event.data.type;
this.createdAt = getDate(event.created);
break;
}
case "account:avatar:added": {
this.avatar = { url: event.data };
this.updatedAt = getDate(event.created);
break;
}
case "account:name:added": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "account:email:added": {
this.contact.emails.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "account:phone:added": {
this.contact.phones.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "strategy:email:added": {
this.strategies.push({ type: "email", value: event.data });
this.updatedAt = getDate(event.created);
break;
}
case "strategy:password:added": {
this.strategies.push({ type: "password", ...event.data });
this.updatedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
addAvatar(url: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:avatar:added",
data: url,
meta,
});
}
addName(name: Name, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:name:added",
data: name,
meta,
});
}
addEmail(email: Email, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:email:added",
data: email,
meta,
});
}
addPhone(phone: Phone, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:phone:added",
data: phone,
meta,
});
}
addRole(roleId: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:role:added",
data: roleId,
meta,
});
}
addEmailStrategy(email: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "strategy:email:added",
data: email,
meta,
});
}
addPasswordStrategy(alias: string, password: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "strategy:password:added",
data: { alias, password },
meta,
});
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
toSession(): Session {
if (this.type === "organization") {
if (this.organizationId === undefined) {
throw new Error("Account .toSession failed, no organization id present");
}
return {
type: this.type,
accountId: this.id,
organizationId: this.organizationId,
};
}
return {
type: this.type,
accountId: this.id,
};
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Session =
| {
type: "organization";
accountId: string;
organizationId: string;
}
| {
type: "admin" | "consultant";
accountId: string;
};
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("account:created", async ({ stream, data }) => {
const schema: any = {
id: stream,
type: data.type,
contact: {
emails: [],
phones: [],
},
strategies: [],
roles: [],
};
if (data.type === "organization") {
schema.organizationId = data.organizationId;
}
await db.collection("accounts").insertOne(toAccountDriver(schema));
});
projector.on("account:avatar:added", async ({ stream: id, data: url }) => {
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } });
});
projector.on("account:name:added", async ({ stream: id, data: name }) => {
await db.collection("accounts").updateOne({ id }, { $set: { name } });
});
projector.on("account:email:added", async ({ stream: id, data: email }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
});
projector.on("account:phone:added", async ({ stream: id, data: phone }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.phones": phone } });
});
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
});
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
await eventStore.relations.insert(Account.emailRelation(email), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
});
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
await eventStore.relations.insert(Account.passwordRelation(strategy.alias), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
});

View File

@@ -0,0 +1,78 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { CodeIdentity } from "../events/code.ts";
import { EventStoreFactory } from "../events/mod.ts";
export class Code extends AggregateRoot<EventStoreFactory> {
static override readonly name = "code";
id!: string;
identity!: CodeIdentity;
value!: string;
createdAt!: Date;
claimedAt?: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Code);
static create(identity: CodeIdentity): Code {
return new Code().push({
type: "code:created",
data: {
identity,
value: crypto
.getRandomValues(new Uint8Array(5))
.map((v) => v % 10)
.join(""),
},
});
}
static async getById(stream: string): Promise<Code | undefined> {
return this.$store.reduce({
name: "code",
stream,
reducer: this.#reducer,
});
}
get isClaimed(): boolean {
return this.claimedAt !== undefined;
}
// -------------------------------------------------------------------------
// Folder
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "code:created": {
this.id = event.stream;
this.value = event.data.value;
this.identity = event.data.identity;
this.createdAt = getDate(event.created);
break;
}
case "code:claimed": {
this.claimedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
claim(): this {
return this.push({
type: "code:claimed",
stream: this.id,
});
}
}

View File

@@ -0,0 +1,8 @@
import { AggregateFactory } from "@valkyr/event-store";
import { Account } from "./account.ts";
import { Code } from "./code.ts";
import { Organization } from "./organization.ts";
import { Role } from "./role.ts";
export const aggregates = new AggregateFactory([Account, Code, Organization, Role]);

View File

@@ -0,0 +1,65 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { db } from "~libraries/read-store/mod.ts";
import { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
export class Organization extends AggregateRoot<EventStoreFactory> {
static override readonly name = "organization";
id!: string;
name!: string;
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Organization);
static create(name: string, meta: Auditor): Organization {
return new Organization().push({
type: "organization:created",
data: { name },
meta,
});
}
static async getById(stream: string): Promise<Organization | undefined> {
return this.$store.reduce({ name: "organization", stream, reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "organization:created": {
this.id = event.stream;
this.name = event.data.name;
this.createdAt = getDate(event.created);
break;
}
}
}
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("organization:created", async ({ stream: id, data: { name }, created }) => {
await db.collection("organizations").insertOne({
id,
name,
createdAt: getDate(created),
});
});

View File

@@ -0,0 +1,118 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { db } from "~libraries/read-store/database.ts";
import type { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
import { projector } from "../projector.ts";
export class Role extends AggregateRoot<EventStoreFactory> {
static override readonly name = "role";
id!: string;
name!: string;
permissions: { [resource: string]: Set<string> } = {};
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Role);
static create(data: RoleCreatedData, meta: Auditor): Role {
return new Role().push({
type: "role:created",
data,
meta,
});
}
static async getById(stream: string): Promise<Role | undefined> {
return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
override with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "role:created": {
this.id = event.stream;
this.createdAt = getDate(event.created);
this.updatedAt = getDate(event.created);
break;
}
case "role:name-set": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "role:permissions-set": {
for (const operation of event.data) {
if (operation.type === "grant") {
if (this.permissions[operation.resource] === undefined) {
this.permissions[operation.resource] = new Set();
}
this.permissions[operation.resource].add(operation.action);
}
if (operation.type === "deny") {
if (operation.action === undefined) {
delete this.permissions[operation.resource];
} else {
this.permissions[operation.resource]?.delete(operation.action);
}
}
}
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
setName(name: string, meta: Auditor): this {
return this.push({
type: "role:name-set",
stream: this.id,
data: name,
meta,
});
}
setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
return this.push({
type: "role:permissions-set",
stream: this.id,
data: operations,
meta,
});
}
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
await db.collection("roles").insertOne({
id: stream,
name,
permissions: permissions.reduce(
(map, permission) => {
map[permission.resource] = permission.actions;
return map;
},
{} as Record<string, string[]>,
),
});
});

View File

@@ -0,0 +1,25 @@
import { EventStore } from "@valkyr/event-store";
import { MongoAdapter } from "@valkyr/event-store/mongo";
import { container } from "~libraries/database/container.ts";
import { aggregates } from "./aggregates/mod.ts";
import { events } from "./events/mod.ts";
import { projector } from "./projector.ts";
export const eventStore = new EventStore({
adapter: new MongoAdapter(() => container.get("client"), "balto:event-store"),
events,
aggregates,
snapshot: "auto",
});
eventStore.onEventsInserted(async (records, { batch }) => {
if (batch !== undefined) {
await projector.pushMany(batch, records);
} else {
for (const record of records) {
await projector.push(record, { hydrated: false, outdated: false });
}
}
});

View File

@@ -0,0 +1,29 @@
import { event } from "@valkyr/event-store";
import { email, name, phone } from "relay/schemas";
import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.discriminatedUnion([
z.object({
type: z.literal("admin"),
}),
z.object({
type: z.literal("consultant"),
}),
z.object({
type: z.literal("organization"),
organizationId: z.string(),
}),
]);
export default [
event.type("account:created").data(created).meta(auditor),
event.type("account:avatar:added").data(z.string()).meta(auditor),
event.type("account:name:added").data(name).meta(auditor),
event.type("account:email:added").data(email).meta(auditor),
event.type("account:phone:added").data(phone).meta(auditor),
event.type("account:role:added").data(z.string()).meta(auditor),
];
export type AccountCreatedData = z.infer<typeof created>;

View File

@@ -0,0 +1,7 @@
import z from "zod";
export const auditor = z.object({
accountId: z.string(),
});
export type Auditor = z.infer<typeof auditor>;

View File

@@ -0,0 +1,30 @@
import { event } from "@valkyr/event-store";
import z from "zod";
const identity = z.discriminatedUnion([
z.object({
type: z.literal("admin"),
accountId: z.string(),
}),
z.object({
type: z.literal("consultant"),
accountId: z.string(),
}),
z.object({
type: z.literal("organization"),
organizationId: z.string(),
accountId: z.string(),
}),
]);
export default [
event.type("code:created").data(
z.object({
value: z.string(),
identity,
}),
),
event.type("code:claimed"),
];
export type CodeIdentity = z.infer<typeof identity>;

View File

@@ -0,0 +1,11 @@
import { EventFactory } from "@valkyr/event-store";
import account from "./account.ts";
import code from "./code.ts";
import organization from "./organization.ts";
import role from "./role.ts";
import strategy from "./strategy.ts";
export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]);
export type EventStoreFactory = typeof events;

View File

@@ -0,0 +1,11 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { auditor } from "./auditor.ts";
export default [
event
.type("organization:created")
.data(z.object({ name: z.string() }))
.meta(auditor),
];

View File

@@ -0,0 +1,37 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.object({
name: z.string(),
permissions: z.array(
z.object({
resource: z.string(),
actions: z.array(z.string()),
}),
),
});
const operation = z.discriminatedUnion([
z.object({
type: z.literal("grant"),
resource: z.string(),
action: z.string(),
}),
z.object({
type: z.literal("deny"),
resource: z.string(),
action: z.string().optional(),
}),
]);
export default [
event.type("role:created").data(created).meta(auditor),
event.type("role:name-set").data(z.string()).meta(auditor),
event.type("role:permissions-set").data(z.array(operation)).meta(auditor),
];
export type RoleCreatedData = z.infer<typeof created>;
export type RolePermissionOperation = z.infer<typeof operation>;

View File

@@ -0,0 +1,13 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { auditor } from "./auditor.ts";
export default [
event.type("strategy:email:added").data(z.string()).meta(auditor),
event.type("strategy:passkey:added").meta(auditor),
event
.type("strategy:password:added")
.data(z.object({ alias: z.string(), password: z.string() }))
.meta(auditor),
];

View File

@@ -0,0 +1,2 @@
export * from "./event-store.ts";
export * from "./projector.ts";

View File

@@ -0,0 +1,5 @@
import { Projector } from "@valkyr/event-store";
import { EventStoreFactory } from "./events/mod.ts";
export const projector = new Projector<EventStoreFactory>();