feat(backend): add domain event bus and wire note CRUD events
- New lib/event-bus.ts: lightweight typed pub/sub with error isolation via Promise.allSettled. Supports note.created, note.updated, note.deleted, task.created, workspace.created events. - notes/repository.ts: emit events on create, update, and delete. - .env.example: add TELEMETRY_ENABLED, FEATURE_FLAGS_ENABLED, FIELD_ENCRYPT_ENABLED, PALACE_ENABLED, PALACE_EXTRACTION_ENABLED.
This commit is contained in:
parent
58a778bc1e
commit
190d23280f
@ -14,6 +14,10 @@ PLATFORM_SERVICE_URL=http://localhost:4003
|
|||||||
EXTRACTION_SERVICE_URL=http://localhost:4005
|
EXTRACTION_SERVICE_URL=http://localhost:4005
|
||||||
MCP_SERVER_URL=http://localhost:4007
|
MCP_SERVER_URL=http://localhost:4007
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
TELEMETRY_ENABLED=false
|
||||||
|
FEATURE_FLAGS_ENABLED=false
|
||||||
|
|
||||||
# LLM (@bytelyst/llm)
|
# LLM (@bytelyst/llm)
|
||||||
LLM_PROVIDER=mock
|
LLM_PROVIDER=mock
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
@ -25,7 +29,12 @@ LLM_VISION_MODEL=gpt-4o
|
|||||||
LLM_EMBEDDING_MODEL=text-embedding-3-small
|
LLM_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
# Field-level encryption
|
# Field-level encryption
|
||||||
|
FIELD_ENCRYPT_ENABLED=true
|
||||||
FIELD_ENCRYPT_KEY_PROVIDER=memory
|
FIELD_ENCRYPT_KEY_PROVIDER=memory
|
||||||
FIELD_ENCRYPT_KEY=
|
FIELD_ENCRYPT_KEY=
|
||||||
FIELD_ENCRYPT_MEK_NAME=notelett-mek
|
FIELD_ENCRYPT_MEK_NAME=notelett-mek
|
||||||
AZURE_KEYVAULT_URL=
|
AZURE_KEYVAULT_URL=
|
||||||
|
|
||||||
|
# Palace (MemPalace)
|
||||||
|
PALACE_ENABLED=true
|
||||||
|
PALACE_EXTRACTION_ENABLED=true
|
||||||
|
|||||||
84
backend/src/lib/event-bus.ts
Normal file
84
backend/src/lib/event-bus.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Domain event bus singleton for NoteLett backend.
|
||||||
|
*
|
||||||
|
* Lightweight typed pub/sub for domain events. Handlers run via
|
||||||
|
* Promise.allSettled — a failing handler never blocks others.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NoteCreatedEvent {
|
||||||
|
noteId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteUpdatedEvent {
|
||||||
|
noteId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteDeletedEvent {
|
||||||
|
noteId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskCreatedEvent {
|
||||||
|
taskId: string;
|
||||||
|
noteId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceCreatedEvent {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NoteLettEventMap = {
|
||||||
|
'note.created': NoteCreatedEvent;
|
||||||
|
'note.updated': NoteUpdatedEvent;
|
||||||
|
'note.deleted': NoteDeletedEvent;
|
||||||
|
'task.created': TaskCreatedEvent;
|
||||||
|
'workspace.created': WorkspaceCreatedEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Handler<T> = (payload: T) => void | Promise<void>;
|
||||||
|
|
||||||
|
class DomainEventBus {
|
||||||
|
private handlers = new Map<string, Set<Handler<unknown>>>();
|
||||||
|
|
||||||
|
on<K extends keyof NoteLettEventMap>(event: K, handler: Handler<NoteLettEventMap[K]>): () => void {
|
||||||
|
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
|
||||||
|
this.handlers.get(event)!.add(handler as Handler<unknown>);
|
||||||
|
return () => { this.handlers.get(event)?.delete(handler as Handler<unknown>); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit<K extends keyof NoteLettEventMap>(event: K, payload: NoteLettEventMap[K]): Promise<void> {
|
||||||
|
const fns = this.handlers.get(event);
|
||||||
|
if (!fns || fns.size === 0) return;
|
||||||
|
await Promise.allSettled([...fns].map(fn => fn(payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAll(): void {
|
||||||
|
this.handlers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _bus: DomainEventBus | null = null;
|
||||||
|
|
||||||
|
export function getEventBus(): DomainEventBus {
|
||||||
|
if (!_bus) {
|
||||||
|
_bus = new DomainEventBus();
|
||||||
|
}
|
||||||
|
return _bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — for testing only. */
|
||||||
|
export function _resetEventBus(): void {
|
||||||
|
_bus = null;
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { getCollection } from '../../lib/datastore.js';
|
import { getCollection } from '../../lib/datastore.js';
|
||||||
import { getEncryptor } from '../../lib/field-encrypt.js';
|
import { getEncryptor } from '../../lib/field-encrypt.js';
|
||||||
|
import { getEventBus } from '../../lib/event-bus.js';
|
||||||
import { isEncryptedField, type EncryptedField } from '@bytelyst/field-encrypt';
|
import { isEncryptedField, type EncryptedField } from '@bytelyst/field-encrypt';
|
||||||
import type { NoteDoc, ListNotesQuery } from './types.js';
|
import type { NoteDoc, ListNotesQuery } from './types.js';
|
||||||
import type { FilterMap } from '@bytelyst/datastore';
|
import type { FilterMap } from '@bytelyst/datastore';
|
||||||
@ -78,7 +79,14 @@ export async function getNote(id: string, workspaceId: string): Promise<NoteDoc
|
|||||||
export async function createNote(doc: NoteDoc): Promise<NoteDoc> {
|
export async function createNote(doc: NoteDoc): Promise<NoteDoc> {
|
||||||
const encrypted = await encryptFields(doc);
|
const encrypted = await encryptFields(doc);
|
||||||
const created = await collection().create(encrypted);
|
const created = await collection().create(encrypted);
|
||||||
return decryptFields(created);
|
const result = await decryptFields(created);
|
||||||
|
getEventBus().emit('note.created', {
|
||||||
|
noteId: result.id,
|
||||||
|
workspaceId: result.workspaceId,
|
||||||
|
userId: result.userId,
|
||||||
|
title: result.title,
|
||||||
|
}).catch(() => {});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteNote(id: string, workspaceId: string): Promise<boolean> {
|
export async function deleteNote(id: string, workspaceId: string): Promise<boolean> {
|
||||||
@ -93,6 +101,11 @@ export async function deleteNote(id: string, workspaceId: string): Promise<boole
|
|||||||
} as NoteDoc);
|
} as NoteDoc);
|
||||||
|
|
||||||
await collection().upsert(merged);
|
await collection().upsert(merged);
|
||||||
|
getEventBus().emit('note.deleted', {
|
||||||
|
noteId: id,
|
||||||
|
workspaceId: existing.workspaceId,
|
||||||
|
userId: existing.userId,
|
||||||
|
}).catch(() => {});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,5 +133,12 @@ export async function updateNote(
|
|||||||
|
|
||||||
const encrypted = await encryptFields(merged);
|
const encrypted = await encryptFields(merged);
|
||||||
const result = await collection().upsert(encrypted);
|
const result = await collection().upsert(encrypted);
|
||||||
return decryptFields(result);
|
const decryptedResult = await decryptFields(result);
|
||||||
|
getEventBus().emit('note.updated', {
|
||||||
|
noteId: decryptedResult.id,
|
||||||
|
workspaceId: decryptedResult.workspaceId,
|
||||||
|
userId: decryptedResult.userId,
|
||||||
|
title: decryptedResult.title,
|
||||||
|
}).catch(() => {});
|
||||||
|
return decryptedResult;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user