diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/ProfileSettingsUI.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx similarity index 100% rename from apps/web/src/apps/showcase/events-demo/auth-sync/ProfileSettingsUI.tsx rename to apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx similarity index 97% rename from apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx rename to apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx index 8d89489..e1fa14f 100644 --- a/apps/web/src/apps/showcase/events-demo/auth-sync/StorageSyncListener.tsx +++ b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx @@ -36,7 +36,7 @@ interface StorageSyncListenerProps { * ``` * * 3. For bidirectional sync (storage → event), consider adding a - * `STORAGE:PROFILE_LOADED` event to `AppEvents` that fires when + * `STORAGE:PROFILE_LOADED` event to `AppEventRegistry` that fires when * the app reads the profile from IndexedDB on boot. * * 4. Error handling: In production, wrap the `setItem` call in a diff --git a/apps/web/src/apps/showcase/events-demo/index.tsx b/apps/web/src/apps/showcase/events-demo/index.tsx index 0581990..664b057 100644 --- a/apps/web/src/apps/showcase/events-demo/index.tsx +++ b/apps/web/src/apps/showcase/events-demo/index.tsx @@ -9,11 +9,11 @@ import { } from '@repo/ui/components'; // ── Showcase Components ────────────────────────────────────────── -import { CashierUI } from './printer/CashierUI'; -import { PrinterListener } from './printer/PrinterListener'; -import { LiveStockGrid } from './stock-grid/LiveStockGrid'; -import { ProfileSettingsUI } from './auth-sync/ProfileSettingsUI'; -import { StorageSyncListener } from './auth-sync/StorageSyncListener'; +import { CashierUI } from './printer/cashier.ui'; +import { PrinterListener } from './printer/printer.listener'; +import { LiveStockGrid } from './stock-grid/live-stock-grid.ui'; +import { ProfileSettingsUI } from './auth-sync/profile-settings.ui'; +import { StorageSyncListener } from './auth-sync/storage-sync.listener'; // ─── Events Demo Page ─────────────────────────────────────────── diff --git a/apps/web/src/apps/showcase/events-demo/printer/CashierUI.tsx b/apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx similarity index 100% rename from apps/web/src/apps/showcase/events-demo/printer/CashierUI.tsx rename to apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx diff --git a/apps/web/src/apps/showcase/events-demo/printer/PrinterListener.tsx b/apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx similarity index 100% rename from apps/web/src/apps/showcase/events-demo/printer/PrinterListener.tsx rename to apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx b/apps/web/src/apps/showcase/events-demo/stock-grid/live-stock-grid.ui.tsx similarity index 98% rename from apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx rename to apps/web/src/apps/showcase/events-demo/stock-grid/live-stock-grid.ui.tsx index e46395f..46738e5 100644 --- a/apps/web/src/apps/showcase/events-demo/stock-grid/LiveStockGrid.tsx +++ b/apps/web/src/apps/showcase/events-demo/stock-grid/live-stock-grid.ui.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Group, Badge, Text, Stack } from '@repo/ui/components'; -import { StockRow } from './StockRow'; -import { generateStockIds, startMockWebSocket } from './MockWebSocket'; +import { StockRow } from './stock-row.ui'; +import { generateStockIds, startMockWebSocket } from './mock-websocket.service'; // ─── Constants ────────────────────────────────────────────────── diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/MockWebSocket.ts b/apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts similarity index 100% rename from apps/web/src/apps/showcase/events-demo/stock-grid/MockWebSocket.ts rename to apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/StockRow.tsx b/apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx similarity index 100% rename from apps/web/src/apps/showcase/events-demo/stock-grid/StockRow.tsx rename to apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx diff --git a/apps/web/src/types/events.d.ts b/apps/web/src/types/events.d.ts new file mode 100644 index 0000000..e95b272 --- /dev/null +++ b/apps/web/src/types/events.d.ts @@ -0,0 +1,76 @@ +/** + * App-level event registry for `apps/web`. + * + * This file uses TypeScript Declaration Merging (Module Augmentation) + * to extend the open `AppEventRegistry` interface exported by + * `@repo/core-events`. This is the IoC pattern in action: + * + * - `@repo/core-events` provides the bus, hooks, and helpers (the tool). + * - `apps/web` defines which events exist and their payload shapes (the contract). + * + * The core package has zero knowledge of these events. If `apps/web` + * is removed from the monorepo, the core package remains unchanged. + * + * **Adding new events**: Simply add new entries to `AppEventRegistry` + * below. TypeScript will automatically provide autocomplete and + * type safety across every `publish()` / `useAppEvent()` call in + * the web app. + * + * @see packages/core-events/src/events.registry.ts + */ + +// This import turns this file from an ambient declaration into a +// module augmentation. Without it, `declare module` would REPLACE +// the module signature instead of merging into it. +import type {} from '@repo/core-events'; + +declare module '@repo/core-events' { + + // ─── Payload Types ────────────────────────────────────────── + + interface ReceiptItem { + name: string; + qty: number; + price: number; + } + + interface PrintReceiptPayload { + receiptId: string; + items: ReceiptItem[]; + total: number; + cashierName: string; + timestamp: number; + } + + interface StockUpdatePayload { + id: string; + price: number; + change: number; + volume: number; + } + + interface ProfileUpdatedPayload { + id: string; + name: string; + email: string; + avatar: string; + updatedAt: number; + } + + // ─── Event Registry ───────────────────────────────────────── + + interface AppEventRegistry { + // ── Device / Hardware ───────────────────────────────────── + 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload; + + // ── WebSocket / Real-Time ──────────────────────────────── + 'WS:STOCK_UPDATE': StockUpdatePayload; + + // ── Auth / User ────────────────────────────────────────── + 'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload; + + // ── App Lifecycle ──────────────────────────────────────── + 'APP:INITIALIZED': undefined; + 'APP:ERROR': { message: string; code?: string }; + } +} diff --git a/packages/core-events/README.md b/packages/core-events/README.md index 3b3c46a..0c9dcc5 100644 --- a/packages/core-events/README.md +++ b/packages/core-events/README.md @@ -6,11 +6,13 @@ `@repo/core-events` is the **decoupled Nervous System** of the ERP. It provides a highly performant, strictly typed Event Bus powered by `mitt` and React hooks. +**This package is a pure tool.** It ships zero application-specific events. Each consuming app (`apps/web`, `apps/landing`, etc.) registers its own events autonomously using TypeScript Declaration Merging — the same Inversion of Control pattern used by `@repo/core-api`'s `createHttpClient` factory. + By routing communication through a centralized event bus, we achieve: +- **App Autonomy**: The core defines the bus. The app defines the contract. No circular knowledge. - **Zero Coupling**: Publishers and subscribers don't need to import or know about each other. - **Extreme Performance**: Components can subscribe to high-frequency data streams (like WebSockets) and update their own local state *without* triggering massive React tree re-renders. - **Memory Safety**: The provided `useAppEvent` hook automatically handles subscription cleanup on component unmount, preventing the most common source of memory leaks in SPA architectures. -- **Strict Contracts**: The `AppEvents` registry enforces payload shapes at compile-time, ensuring publishers and subscribers always agree on the data contract. --- @@ -18,23 +20,27 @@ By routing communication through a centralized event bus, we achieve: ```mermaid graph TD - subgraph Publishers + subgraph "@repo/core-events (Pure Tool)" + R["AppEventRegistry
(empty interface)"] + T["AppEvents = mapped type"] + E((Event Bus
mitt)) + H[useAppEvent / usePublishEvent] + R --> T --> E + E --> H + end + + subgraph "apps/web (App Autonomy)" + D["events.d.ts
declare module augmentation"] A[Cashier UI] B[Profile Settings] C[WebSocket Client] - end - - subgraph Core - E((Event Bus
mitt)) - R[[AppEvents
Registry]] -.-> E - end - - subgraph Subscribers X[Electron IPC Bridge] Y[IndexedDB Sync] Z[Stock Grid Row] end + D -. "merges into" .-> R + A -- "DEVICE:PRINT_RECEIPT" --> E B -- "AUTH:PROFILE_UPDATED" --> E C -- "WS:STOCK_UPDATE" --> E @@ -45,38 +51,82 @@ graph TD style E fill:#4263eb,color:#fff,stroke:#fff style R fill:#2b8a3e,color:#fff,stroke:#fff + style D fill:#e67700,color:#fff,stroke:#fff ``` --- -## Defining Events +## Defining Events (Module Augmentation) -Every event in the system MUST be registered in `src/events.registry.ts`. This provides a single source of truth and full autocomplete across the codebase. +> [!IMPORTANT] +> **Do NOT add application events to `packages/core-events/src/events.registry.ts`.** +> The core registry is intentionally empty. Each app owns its own event contract. -To add a new event, simply extend the `AppEvents` type: +The core exports an open `AppEventRegistry` interface. Apps extend it using TypeScript's `declare module` syntax — the same pattern used for `@types/*` across the JS ecosystem. + +### Step 1: Create an augmentation file in your app + +> [!WARNING] +> The `import type {}` line is **mandatory**. Without it, TypeScript treats `declare module` as an ambient module declaration that **replaces** the module's types instead of merging into them. All actual exports (`useAppEvent`, `publish`, etc.) would become invisible. ```typescript -// packages/core-events/src/events.registry.ts +// apps/web/src/types/events.d.ts -export interface CheckoutPayload { - orderId: string; - total: number; +// This import makes this file a module augmentation (merge) +// instead of an ambient declaration (replace). +import type {} from '@repo/core-events'; + +declare module '@repo/core-events' { + // Define your payload shapes + interface OrderPayload { + orderId: string; + total: number; + items: Array<{ sku: string; qty: number }>; + } + + // Extend the registry + interface AppEventRegistry { + 'STORE:ORDER_PLACED': OrderPayload; + 'STORE:ORDER_CANCELLED': { orderId: string; reason: string }; + 'UI:SIDEBAR_TOGGLED': { collapsed: boolean }; + } +} +``` + +### Step 2: Use it — autocomplete works immediately + +```tsx +import { usePublishEvent, useAppEvent } from '@repo/core-events'; + +function CheckoutButton() { + const publish = usePublishEvent(); + // ✅ 'STORE:ORDER_PLACED' autocompletes. + // ✅ Payload shape is enforced by TypeScript. + publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [...] }); } -export type AppEvents = { - // Existing events... - 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload; - - // Your new event: - 'STORE:CHECKOUT_COMPLETED': CheckoutPayload; -}; +function OrderTracker() { + // ✅ payload is fully typed as OrderPayload + useAppEvent('STORE:ORDER_PLACED', (payload) => { + console.log(payload.orderId); // string + }); +} ``` +### Why this pattern? + +| Concern | Old (Hardcoded) | New (Module Augmentation) | +|---|---|---| +| Core knows about app events? | ❌ Yes — violates IoC | ✅ No — core is a pure tool | +| Adding events requires editing core? | ❌ Yes | ✅ No — edit your app's `.d.ts` only | +| Multiple apps share the same registry? | ❌ Collision risk | ✅ Each app has its own `.d.ts` | +| Type safety / autocomplete | ✅ Works | ✅ Works identically | + --- ## Usage Examples -Here are three real-world architectural patterns powered by the Event Bus. +Here are three real-world architectural patterns powered by the Event Bus. All event types below are registered in `apps/web/src/types/events.d.ts`, **not** in the core package. ### Example 1: Hardware Abstraction (Cross-Platform) @@ -97,7 +147,8 @@ export function CashierUI() { receiptId: 'RCP-123', items: [...], total: 45.00, - cashierName: 'Firman' + cashierName: 'Firman', + timestamp: Date.now(), }); }; @@ -198,6 +249,8 @@ export function ProfileSettingsUI() { id: 'user-1', name: 'Firman', email: 'firman@eigen.co.id', + avatar: 'https://example.com/avatar.png', + updatedAt: Date.now(), }); }; diff --git a/packages/core-events/src/event-bus.test.ts b/packages/core-events/src/event-bus.test.ts index 0e47d92..ca807b6 100644 --- a/packages/core-events/src/event-bus.test.ts +++ b/packages/core-events/src/event-bus.test.ts @@ -2,7 +2,40 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { eventBus, publish, subscribe } from './event-bus'; import { useAppEvent, usePublishEvent } from './hooks'; -import type { StockUpdatePayload, ProfileUpdatedPayload } from './events.registry'; + +// ─── Test-Local Event Augmentation ────────────────────────────── +// +// In a real app, these would live in a `.d.ts` file inside the app's +// `src/types/` folder using `declare module '@repo/core-events'`. +// +// For the core package's own test suite, we augment the registry +// directly here so the tests have concrete event types to work with +// without polluting the core's shipped types. +// +declare module './events.registry' { + interface AppEventRegistry { + 'TEST:STOCK_UPDATE': StockUpdatePayload; + 'TEST:PROFILE_UPDATED': ProfileUpdatedPayload; + 'TEST:INITIALIZED': undefined; + } +} + +// ─── Test Payload Types ───────────────────────────────────────── + +interface StockUpdatePayload { + id: string; + price: number; + change: number; + volume: number; +} + +interface ProfileUpdatedPayload { + id: string; + name: string; + email: string; + avatar: string; + updatedAt: number; +} // ─── Helpers ──────────────────────────────────────────────────── @@ -42,8 +75,8 @@ describe('Event Bus (Core)', () => { it('publishes and subscribes to a typed event', () => { const handler = vi.fn(); - subscribe('WS:STOCK_UPDATE', handler); - publish('WS:STOCK_UPDATE', mockStockUpdate); + subscribe('TEST:STOCK_UPDATE', handler); + publish('TEST:STOCK_UPDATE', mockStockUpdate); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(mockStockUpdate); @@ -53,9 +86,9 @@ describe('Event Bus (Core)', () => { const handler1 = vi.fn(); const handler2 = vi.fn(); - subscribe('AUTH:PROFILE_UPDATED', handler1); - subscribe('AUTH:PROFILE_UPDATED', handler2); - publish('AUTH:PROFILE_UPDATED', mockProfile); + subscribe('TEST:PROFILE_UPDATED', handler1); + subscribe('TEST:PROFILE_UPDATED', handler2); + publish('TEST:PROFILE_UPDATED', mockProfile); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).toHaveBeenCalledTimes(1); @@ -65,10 +98,10 @@ describe('Event Bus (Core)', () => { const stockHandler = vi.fn(); const profileHandler = vi.fn(); - subscribe('WS:STOCK_UPDATE', stockHandler); - subscribe('AUTH:PROFILE_UPDATED', profileHandler); + subscribe('TEST:STOCK_UPDATE', stockHandler); + subscribe('TEST:PROFILE_UPDATED', profileHandler); - publish('WS:STOCK_UPDATE', mockStockUpdate); + publish('TEST:STOCK_UPDATE', mockStockUpdate); expect(stockHandler).toHaveBeenCalledTimes(1); expect(profileHandler).not.toHaveBeenCalled(); @@ -77,15 +110,15 @@ describe('Event Bus (Core)', () => { it('unsubscribes correctly via returned function', () => { const handler = vi.fn(); - const unsub = subscribe('WS:STOCK_UPDATE', handler); + const unsub = subscribe('TEST:STOCK_UPDATE', handler); - publish('WS:STOCK_UPDATE', mockStockUpdate); + publish('TEST:STOCK_UPDATE', mockStockUpdate); expect(handler).toHaveBeenCalledTimes(1); // Unsubscribe unsub(); - publish('WS:STOCK_UPDATE', mockStockUpdate); + publish('TEST:STOCK_UPDATE', mockStockUpdate); // Should still be 1, not 2 expect(handler).toHaveBeenCalledTimes(1); }); @@ -93,8 +126,8 @@ describe('Event Bus (Core)', () => { it('handles events with undefined payloads', () => { const handler = vi.fn(); - subscribe('APP:INITIALIZED', handler); - publish('APP:INITIALIZED', undefined); + subscribe('TEST:INITIALIZED', handler); + publish('TEST:INITIALIZED', undefined); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(undefined); @@ -102,10 +135,10 @@ describe('Event Bus (Core)', () => { it('handles rapid-fire events without loss', () => { const handler = vi.fn(); - subscribe('WS:STOCK_UPDATE', handler); + subscribe('TEST:STOCK_UPDATE', handler); for (let i = 0; i < 1000; i++) { - publish('WS:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` }); + publish('TEST:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` }); } expect(handler).toHaveBeenCalledTimes(1000); @@ -126,10 +159,10 @@ describe('useAppEvent (React Hook)', () => { it('subscribes on mount and receives events', () => { const handler = vi.fn(); - renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler)); + renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler)); act(() => { - publish('WS:STOCK_UPDATE', mockStockUpdate); + publish('TEST:STOCK_UPDATE', mockStockUpdate); }); expect(handler).toHaveBeenCalledTimes(1); @@ -139,11 +172,11 @@ describe('useAppEvent (React Hook)', () => { it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => { const handler = vi.fn(); - const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler)); + const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler)); // Event should be received while mounted act(() => { - publish('WS:STOCK_UPDATE', mockStockUpdate); + publish('TEST:STOCK_UPDATE', mockStockUpdate); }); expect(handler).toHaveBeenCalledTimes(1); @@ -152,7 +185,7 @@ describe('useAppEvent (React Hook)', () => { // Event should NOT be received after unmount act(() => { - publish('WS:STOCK_UPDATE', mockStockUpdate); + publish('TEST:STOCK_UPDATE', mockStockUpdate); }); // Still 1, proving the handler was properly cleaned up @@ -164,20 +197,20 @@ describe('useAppEvent (React Hook)', () => { // Mount and unmount 100 times for (let i = 0; i < 100; i++) { - const { unmount } = renderHook(() => useAppEvent('WS:STOCK_UPDATE', handler)); + const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler)); unmount(); } // After 100 cycles, emit one event act(() => { - publish('WS:STOCK_UPDATE', mockStockUpdate); + publish('TEST:STOCK_UPDATE', mockStockUpdate); }); // Should be 0 — all handlers should have been cleaned up expect(handler).toHaveBeenCalledTimes(0); // Verify the handler map is empty for this event type - const handlers = eventBus.all.get('WS:STOCK_UPDATE'); + const handlers = eventBus.all.get('TEST:STOCK_UPDATE'); expect(!handlers || handlers.length === 0).toBe(true); }); @@ -186,7 +219,7 @@ describe('useAppEvent (React Hook)', () => { const { rerender } = renderHook( ({ value }: { value: string }) => - useAppEvent('AUTH:PROFILE_UPDATED', () => { + useAppEvent('TEST:PROFILE_UPDATED', () => { capturedValue = value; }), { initialProps: { value: 'initial' } }, @@ -196,7 +229,7 @@ describe('useAppEvent (React Hook)', () => { rerender({ value: 'updated' }); act(() => { - publish('AUTH:PROFILE_UPDATED', mockProfile); + publish('TEST:PROFILE_UPDATED', mockProfile); }); // Should capture the LATEST value, not the stale 'initial' @@ -210,7 +243,7 @@ describe('useAppEvent (React Hook)', () => { const { rerender } = renderHook( ({ handler }: { handler: () => void }) => - useAppEvent('APP:INITIALIZED', handler), + useAppEvent('TEST:INITIALIZED', handler), { initialProps: { handler: vi.fn() } }, ); @@ -240,12 +273,12 @@ describe('usePublishEvent (React Hook)', () => { it('returns a working publish function', () => { const handler = vi.fn(); - subscribe('AUTH:PROFILE_UPDATED', handler); + subscribe('TEST:PROFILE_UPDATED', handler); const { result } = renderHook(() => usePublishEvent()); act(() => { - result.current('AUTH:PROFILE_UPDATED', mockProfile); + result.current('TEST:PROFILE_UPDATED', mockProfile); }); expect(handler).toHaveBeenCalledTimes(1); diff --git a/packages/core-events/src/events.registry.ts b/packages/core-events/src/events.registry.ts index 229d348..a27bc78 100644 --- a/packages/core-events/src/events.registry.ts +++ b/packages/core-events/src/events.registry.ts @@ -1,82 +1,50 @@ -// ─── Event Payload Types ──────────────────────────────────────── +// ─── Application Event Registry (IoC Pattern) ────────────────── /** - * Receipt line item for the DEVICE:PRINT_RECEIPT event. - */ -export interface ReceiptItem { - name: string; - qty: number; - price: number; -} - -/** - * Payload for the DEVICE:PRINT_RECEIPT event. - */ -export interface PrintReceiptPayload { - receiptId: string; - items: ReceiptItem[]; - total: number; - cashierName: string; - timestamp: number; -} - -/** - * Payload for the WS:STOCK_UPDATE event. - */ -export interface StockUpdatePayload { - id: string; - price: number; - change: number; - volume: number; -} - -/** - * Payload for the AUTH:PROFILE_UPDATED event. - */ -export interface ProfileUpdatedPayload { - id: string; - name: string; - email: string; - avatar: string; - updatedAt: number; -} - -// ─── Application Event Registry ──────────────────────────────── - -/** - * Central event registry for the entire application. + * Open interface for application event registration. * - * Every event in the system MUST be declared here with its payload - * type. This provides: + * **This interface is intentionally empty at the core level.** * - * 1. **Compile-time safety** — typos in event names are caught by TS. - * 2. **Payload validation** — publishers and subscribers agree on shape. - * 3. **Discoverability** — `Ctrl+Click` any event to find its contract. + * Each consuming app (`apps/web`, `apps/landing`, etc.) is responsible + * for registering its own events using TypeScript Declaration Merging + * (Module Augmentation). This enforces Inversion of Control: * - * **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`. + * - The core package provides the **tool** (bus, hooks, helpers). + * - The app provides the **contract** (event names and payloads). * - * **Extensibility**: To add events from feature modules, extend this - * type using intersection: + * ## How to register events + * + * Create a `.d.ts` file anywhere in your app's `src/` folder: * * ```ts - * // In your feature module types: - * type InventoryEvents = { - * 'INVENTORY:LOW_STOCK': { productId: string; currentQty: number }; - * }; - * // Then merge into AppEvents in this file. + * // apps/web/src/types/events.d.ts + * declare module '@repo/core-events' { + * interface AppEventRegistry { + * 'DOMAIN:EVENT_NAME': { payload: string }; + * } + * } * ``` + * + * TypeScript will automatically merge all augmentations into a single + * `AppEventRegistry` interface, giving you full autocomplete and + * compile-time type safety across the entire app — without the core + * package knowing anything about your events. + * + * **Naming convention**: `DOMAIN:ACTION` in `SCREAMING_SNAKE_CASE`. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AppEventRegistry {} + +/** + * Resolved event map consumed by `mitt` and all public APIs. + * + * This type alias bridges the open `AppEventRegistry` interface + * (which supports declaration merging) to the `Record` + * constraint that `mitt` requires. + * + * You should never reference this type directly in consumer code. + * Use `AppEventRegistry` for augmentation and let the core handle the rest. */ export type AppEvents = { - // ── Device / Hardware ─────────────────────────────────────────── - 'DEVICE:PRINT_RECEIPT': PrintReceiptPayload; - - // ── WebSocket / Real-Time ─────────────────────────────────────── - 'WS:STOCK_UPDATE': StockUpdatePayload; - - // ── Auth / User ───────────────────────────────────────────────── - 'AUTH:PROFILE_UPDATED': ProfileUpdatedPayload; - - // ── App Lifecycle ─────────────────────────────────────────────── - 'APP:INITIALIZED': undefined; - 'APP:ERROR': { message: string; code?: string }; + [K in keyof AppEventRegistry]: AppEventRegistry[K]; }; diff --git a/packages/core-events/src/index.ts b/packages/core-events/src/index.ts index 605928a..985dd7b 100644 --- a/packages/core-events/src/index.ts +++ b/packages/core-events/src/index.ts @@ -1,11 +1,5 @@ // ─── Event Registry ───────────────────────────────────────────── -export type { - AppEvents, - PrintReceiptPayload, - StockUpdatePayload, - ProfileUpdatedPayload, - ReceiptItem, -} from './events.registry'; +export type { AppEventRegistry, AppEvents } from './events.registry'; // ─── Event Bus (Core) ─────────────────────────────────────────── export { eventBus, publish, subscribe } from './event-bus';