@happyvertical/smrt-events
Infinite-nesting event hierarchy with series, participant tracking, recurrence patterns, and a dedicated event-owned asset join.
Overview
smrt-events provides infinite-nesting event hierarchy with series,
participant tracking, recurrence patterns, and the new EventAsset join table for event-owned
assets. Events can model anything from conferences with sessions to sports games with periods and
goals.
Tenancy
All event models use @TenantScoped({ mode: 'optional' }) with a nullable tenantId. Global events (tenantId = null) are visible to every
tenant — useful for shared reference calendars (public holidays, market hours).
Installation
npm install @happyvertical/smrt-eventsQuick Start
import {
EventCollection,
EventSeriesCollection,
EventParticipantCollection,
} from '@happyvertical/smrt-events';
const events = await EventCollection.create({ db });
const series = await EventSeriesCollection.create({ db });
const participants = await EventParticipantCollection.create({ db });
const playoffs = await series.create({
name: 'NBA Playoffs 2026',
typeId: tournamentTypeId,
organizerId: nbaProfileId,
startDate: new Date('2026-04-15'),
endDate: new Date('2026-06-20'),
});
await playoffs.save();
const game = await events.create({
name: 'Lakers vs Warriors',
seriesId: playoffs.id,
typeId: gameTypeId,
placeId: arenaId, // FK to smrt-places (plain string, cross-package)
startDate: new Date('2026-04-20T19:00:00'),
endDate: new Date('2026-04-20T21:30:00'),
status: 'scheduled',
});
await game.save();
await participants.create({
eventId: game.id,
profileId: lakersProfileId,
role: 'home',
placement: 0,
});
await participants.create({
eventId: game.id,
profileId: warriorsProfileId,
role: 'away',
placement: 1,
});Owned Assets via EventAsset (new)
event_assets is the canonical join for event-owned assets (recordings, slide
decks, photos, agendas). Use the helpers on Event and EventCollection; reserve generic AssetAssociation for cross-cutting /
provenance links that don't belong to a single owner.
// addAsset takes an Asset instance, a relationship string, and an optional sortOrder
await game.addAsset(recordingAsset, 'recording', 0);
const recordings = await game.getAssets('recording');
await game.removeAsset(recordingAsset.id);
// Collection-level helpers take the event id explicitly
await events.addAsset(game.id, recordingAsset, 'recording', 0);
const byEvent = await events.getAssets(game.id, 'recording');Core Models
Event (Hierarchical, STI)
class Event extends SmrtHierarchical {
name: string
seriesId?: string // FK to EventSeries
parentId?: string | null // Self-referencing hierarchy UUID (no depth limit!)
typeId: string // FK to EventType
placeId?: string // FK to Place (cross-package plain string)
description?: string
startDate?: Date | null
endDate?: Date | null
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed'
round?: number | null // Sequence/round number in series
metadata?: string // JSON string with get/set/update helpers
// Hierarchy navigation (inherited from SmrtHierarchical)
async getParent(): Promise<Event | null>
async getChildren(): Promise<Event[]>
async getAncestors(): Promise<Event[]>
async getDescendants(): Promise<Event[]>
async getRootEvent(): Promise<Event>
async getHierarchy(): Promise<{ ancestors; current; descendants }>
// Participants
async getParticipants(): Promise<EventParticipant[]>
// Owned assets via event_assets (positional args; addAsset takes an Asset instance)
async getAssets(relationship?: string): Promise<Asset[]>
async addAsset(asset: Asset, relationship?: string, sortOrder?: number): Promise<void>
async removeAsset(assetId: string, relationship?: string): Promise<void>
}EventSeries (Recurring)
class EventSeries extends SmrtObject {
name: string
typeId: string
organizerId: string // FK to Profile (cross-package plain string)
recurrence?: RecurrencePattern
}
interface RecurrencePattern {
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval?: number
count?: number
until?: Date
byDay?: string[]
byMonthDay?: number[]
byMonth?: number[]
}EventParticipant
// conflictColumns: ['event_id', 'profile_id', 'role']
class EventParticipant extends SmrtObject {
eventId: string // FK to Event (cross-package plain string)
profileId: string // FK to Profile (cross-package plain string)
role: string // 'home' | 'away' | 'speaker' | 'panelist' | etc.
placement?: number // Numeric: 0=home, 1=away, or ranking position
groupId?: string // Logical grouping (e.g. team members in a game)
// NOT enforced at DB level
metadata?: Record<string, any>
}Hierarchical Events
// game → quarters → individual goal events
const q1 = await events.create({
name: '1st Quarter',
parentId: game.id,
startDate: new Date('2026-04-20T19:00:00'),
endDate: new Date('2026-04-20T19:12:00'),
typeId: periodTypeId,
});
await q1.save();
const goal = await events.create({
name: 'LeBron 3-pointer',
parentId: q1.id,
startDate: new Date('2026-04-20T19:05:23'),
typeId: scoreTypeId,
metadata: JSON.stringify({ points: 3, player: 'LeBron James' }),
});
await goal.save();
const ancestors = await goal.getAncestors(); // [q1, game]
const root = await goal.getRootEvent(); // gameGotchas
- No depth limit on event hierarchy — deep nesting can cause N+1 queries
(load with
getDescendants()in batch) placementis numeric: used for both team ordering (0=home, 1=away) and rankings — context-dependentgroupIdnot enforced at DB level: for logical grouping only (e.g. team members in a game)- Optional tenancy with nullable
tenantId - Metadata stored as JSON string with get/set/update helpers — graceful parse-error handling
- Owned asset helpers: use
Event.getAssets()/addAsset()/removeAsset()or the matchingEventCollectionwrappers instead of genericAssetAssociation
UI Components
The @happyvertical/smrt-events package includes a Svelte 5 component for displaying
meeting information.
Available Components
Usage Example
<script>
import { MeetingView } from '@happyvertical/smrt-events/svelte';
const meeting = {
id: 'meeting-123',
name: 'Town Council Meeting',
startDate: '2026-01-15T19:00:00',
status: 'scheduled',
agendaUrl: '/docs/agenda.pdf',
minutesUrl: '/docs/minutes.pdf',
videoUrl: 'https://youtube.com/watch?v=...'
};
</script>
<MeetingView {meeting} calendarUrl="/calendar" />Best Practices
DOs
- Use event series for recurring events
- Initialize default event types with
initializeDefaults() - Use
placementfor home/away or speaker order — keep the convention consistent - Use
event_assets(viaaddAsset()) for recordings, agendas, slide decks - Eager-load descendants in batch instead of recursing one level at a time
DON'Ts
- Don't create circular hierarchies (parent referencing child)
- Don't store large binary data in metadata — use
event_assets - Don't transition completed events back to other states
- Don't rely on
groupIdfor referential integrity — it's not FK-enforced