first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and concrete error class for
`@suaveplan/api-plugin-events`. Every failure path in the plugin —
configuration validation, lifecycle wiring, repository write paths,
route-handler authorisation — raises an `EventsPluginError` carrying
exactly one of the twelve `EVENTS_PLUGIN.*` codes. The registry is the
contract: consumers branch on `error.code === EVENTS_PLUGIN_CODES.RSVP_NOT_FOUND`
and receive `EventsPluginErrorCode` narrowing for free.
The namespace literal is `"EVENTS_PLUGIN"` rather than the obvious
`"EVENTS"` to avoid a hard collision with `@suaveplan/events` (the
typed event-bus already shipped ). The event-bus library
owns the `EVENTS_*` prefix; this plugin uses the longer prefix so
both registries can coexist in the same project without either
having to rename codes downstream.
## Features
- 12 documented codes covering every public failure surface.
- `EventsPluginError` extends `SuaveplanError` so `instanceof` narrows
`error.code` to `EventsPluginErrorCode`.
- Side-effect-free import — `import "./error-codes.js"` for code
registry registration with zero runtime cost.
- `cause` chain + `context` bag forwarded to the
`@suaveplan/error` base class for OTel span events and i18n
resolution.
## Usage: Basic Example
```ts
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "@suaveplan/api-plugin-events";
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
);
```
## Usage: Advanced Example
```ts
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
type EventsPluginErrorCode,
} from "@suaveplan/api-plugin-events";
function describe(code: EventsPluginErrorCode): string {
switch (code) {
case EVENTS_PLUGIN_CODES.INVALID_CONFIG:
return "configuration rejected by Zod";
case EVENTS_PLUGIN_CODES.EVENT_CREATE_FAILED:
return "adapter returned no row from CREATE event";
default:
return code;
}
}
try {
// ...
} catch (err) {
if (err instanceof EventsPluginError) {
const label = describe(err.code);
// forward to logger / OTel
console.error(label, err.context, err.cause);
}
}
```
## API Reference
### `EVENTS_PLUGIN_CODES`
A frozen object mapping each code key to its stable string value:
- `INVALID_CONFIG` — Zod-validated options were rejected.
- `SERVICE_NOT_AVAILABLE` — required `ServiceRegistry` slot missing.
- `ROUTE_PREFIX_COLLISION` — `onReady` would shadow existing routes.
- `EVENT_CREATE_FAILED` / `EVENT_UPDATE_FAILED` — adapter returned no row.
- `RSVP_CREATE_FAILED` / `RSVP_UPDATE_FAILED` — adapter returned no row.
- `RSVP_NOT_FOUND` / `EVENT_NOT_FOUND` — lookup miss.
- `UNAUTHENTICATED` — `c.get("user")` was missing on a route.
- `FORBIDDEN` — non-creator hit a mutating endpoint.
- `INVITE_LIMIT_EXCEEDED` — `user_ids` length exceeded the runtime cap.
### `EventsPluginErrorCode`
Union of all values in `EVENTS_PLUGIN_CODES` — useful for exhaustive
`switch` over caught errors.
### `EventsPluginError`
```ts
class EventsPluginError extends SuaveplanError<EventsPluginErrorCode> {
constructor(
code: EventsPluginErrorCode,
message: string,
options?: SuaveplanErrorOptions
);
readonly eventsPluginCode: EventsPluginErrorCode;
}
```
## Implementation Notes
The 12-code registry is fixed for `0.x` — adding a code is a minor
bump, removing or renaming one is a major bump. The
`error-codes.test.ts` reflects over the registry and grep-asserts
each value reaches a `throw new EventsPluginError` call site under
`src/`; that test is the canonical proof that no code is a dangling
declaration. Removing a code to make the test pass is explicitly
banned by `AGENTS.md` §2c — wire the missing throw site
instead.

View File

@@ -0,0 +1,149 @@
# ics-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ics-builder` module emits a complete RFC 5545 `VCALENDAR` document
for a single event document loaded from `EventRepository`. It is a
dependency-free, pure-function module so the same builder can be reused
by future surfaces (cron-driven email digests, signed feed URLs,
calendar-app push delivery) without dragging in route plumbing or a
third-party `ical` library.
The builder is the load-bearing piece of the `GET /:id/ics` endpoint
shipped in `add-events-ics-export`: every escape rule, line-fold
decision, and CRLF terminator the spec mandates lives in this module
and is unit-tested at the byte level. Routes only assemble inputs and
forward the resulting string.
## Features
- `buildVCalendar({ event, attendees?, nowMs })` — top-level builder.
Emits one `BEGIN:VCALENDAR` … `END:VCALENDAR` block containing one
`VEVENT`. When `attendees` is provided, one `ATTENDEE` line is
appended per row.
- `escapeText(raw)` — RFC 5545 §3.3.11 text escape (`\\`, `\;`, `\,`,
CR/LF → `\n`).
- `foldLine(line)` — RFC 5545 §3.1 line folding at 75-octet boundaries.
UTF-8 safe (pulls cut back to a start byte when needed).
- `formatDateUtc(ms)` — UTC `YYYYMMDDTHHMMSSZ` form.
- `mapStatus(intStatus)` — event status integer → VEVENT `STATUS`
string (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`).
- `mapAttendeeStatus(rsvpStatus)` → `PARTSTAT` value (`going`→
`ACCEPTED`, `maybe`→`TENTATIVE`, `not_going`→`DECLINED`,
`invited`→`NEEDS-ACTION`).
- CRLF (`\r\n`) line endings throughout; every output line including
the final `END:VCALENDAR` terminates with CRLF.
## Usage: Basic
```ts
import { buildVCalendar } from "@suaveplan/api-plugin-events/ics/ics-builder/ics-builder";
const ics = buildVCalendar({
event: {
id: "event:1",
title: "Quarterly review",
description: "Planning + retro.",
start_time: "2026-06-01T10:00:00Z",
end_time: "2026-06-01T11:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
},
nowMs: Date.parse("2026-05-20T12:00:00Z"),
});
// → "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:...\r\nBEGIN:VEVENT\r\n..."
```
## Usage: Advanced
Emit the `?with=rsvps` variant by passing an attendee roster. The
builder maps each RSVP status to its `PARTSTAT` parameter and escapes
the display name per §3.3.11.
```ts
import { buildVCalendar } from "@suaveplan/api-plugin-events/ics/ics-builder/ics-builder";
const ics = buildVCalendar({
event: {
id: "event:1",
title: "Annual gala; tickets, mandatory",
start_time: "2026-12-31T18:00:00Z",
end_time: "2027-01-01T00:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 1, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
},
attendees: [
{
id: "event_rsvp:1",
user: "user:42",
user_name: "Ada, of Lovelace",
user_email: "ada@example.com",
status: "going",
},
{
id: "event_rsvp:2",
user: "user:43",
status: "maybe",
},
],
nowMs: Date.parse("2026-05-20T12:00:00Z"),
});
// The first attendee renders as:
// ATTENDEE;PARTSTAT=ACCEPTED;CN=Ada\, of Lovelace:mailto:ada@example.com
// The second falls back to the non-routable @suaveplan.local domain:
// ATTENDEE;PARTSTAT=TENTATIVE:mailto:user:43@suaveplan.local
```
## API Reference
- `buildVCalendar(input: BuildVCalendarInput): string` — returns the
full VCALENDAR document, terminated by CRLF. Throws nothing —
malformed input (e.g. unparseable `start_time`) produces a
best-effort `Invalid Date`-shaped UTC stamp which is the
responsibility of the calling route to detect and avoid.
- `escapeText(raw: string): string` — escapes backslash, semicolon,
comma, and any CR/LF to `\n` (two literal characters: `\` + `n`).
- `foldLine(line: string): string` — folds long lines per §3.1.
Returns the input unchanged when ≤75 octets.
- `formatDateUtc(ms: number): string` — UTC date-time per §3.3.5.
- `mapStatus(status: number): IcsEventStatus` — total function.
Defaults to `CONFIRMED` for any status other than `0` or `2`.
- `mapAttendeeStatus(status: string): IcsAttendeePartStat` — total
function. Returns `NEEDS-ACTION` for unknown / `invited` values.
## Implementation Notes
- **No `ical` dependency.** The 75-octet fold + four escape rules are
small enough to maintain locally; pulling a third-party module would
add a transitive surface that has historically caused calendar-app
interop bugs (escaping policy varies). The spec is RFC-pinned, not
library-pinned.
- **UTC only.** Events on the plugin are stored UTC per the existing
service contract. VTIMEZONE blocks are a follow-up change and not
needed until the plugin learns to store / surface a non-UTC tzid.
- **`SEQUENCE:0`.** The event schema has no `revision` / `version`
field. Until one ships, every export emits `SEQUENCE:0`. The
CALDAV / iCloud conformance rule of "bump SEQUENCE on every
mutation" therefore has nothing to do here; the constant is
forward-safe and is preferred to omission.
- **UTF-8 fold safety.** When the 75-octet boundary lands inside a
multi-byte sequence, the cut is pulled back to the previous start
byte. The maximum reduction is 3 octets (the longest BMP
continuation). Pure-ASCII input — the common case — folds at
exactly 75 octets.
- **`ORGANIZER` not emitted.** The event schema has no host email
field. The omission is documented in the proposal so a future
schema migration can plug the value in without re-litigating the
decision.

View File

@@ -0,0 +1,119 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bundles the seven OpenTelemetry counters emitted by
`@suaveplan/api-plugin-events`. Every counter is lazily built on the
first `getEventsInstruments()` call and cached against the global
`MeterProvider` in effect at that point. Tests that install their own
in-memory `MeterProvider` call `resetEventsInstruments()` before
re-resolving the bundle so the counters bind to the freshly-installed
provider.
The seven instruments form the canonical observability surface for
the plugin: they count successful event lifecycle events (create /
update / delete), RSVP writes partitioned by status, invitations sent,
ICS exports served partitioned by `with` variant, and ICS export
failures partitioned by reason.
Histograms (e.g. route duration) are intentionally not part of the
0.1 surface; the canonical histogram for HTTP routes lives in the
host `@suaveplan/api` middleware layer.
## Features
- Seven `Counter`s emitted from the route handlers:
`events.created_total`, `events.updated_total`,
`events.deleted_total`, `events.rsvps_written_total{status}`,
`events.invitations_sent_total`, `events.ics.served_total{with}`,
`events.ics.failed_total{reason}`.
- Lazily built and cached — no instrument is constructed before the
first `getEventsInstruments()` call.
- `resetEventsInstruments()` clears the cache so tests can re-bind
against a fresh `MeterProvider`.
- Stable name shape (`events.*_total`) for OTel collector scraping.
## Usage: Basic Example
```ts
import { getEventsInstruments } from "@suaveplan/api-plugin-events";
const inst = getEventsInstruments();
inst.eventsCreatedTotal.add(1);
inst.rsvpsWrittenTotal.add(1, { status: "going" });
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import {
MeterProvider,
PeriodicExportingMetricReader,
InMemoryMetricExporter,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import {
getEventsInstruments,
resetEventsInstruments,
} from "@suaveplan/api-plugin-events";
// Install a fresh in-memory exporter for assertions
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const provider = new MeterProvider({
readers: [new PeriodicExportingMetricReader({ exporter })],
});
metrics.setGlobalMeterProvider(provider);
resetEventsInstruments();
// Drive a counter and flush
getEventsInstruments().invitationsSentTotal.add(3);
await provider.forceFlush();
const snapshot = exporter.getMetrics();
// snapshot now contains `events.invitations_sent_total` with value 3
```
## API Reference
### `interface EventsInstruments`
- `eventsCreatedTotal: Counter` — incremented on every successful `POST /`.
- `eventsUpdatedTotal: Counter` — incremented on every successful `PUT /:id`.
- `eventsDeletedTotal: Counter` — incremented on every successful `DELETE /:id`.
- `rsvpsWrittenTotal: Counter` — incremented on every successful
`POST /:id/rsvp`, labelled by the new status (`going` /
`maybe` / `invited` / `not_going`).
- `invitationsSentTotal: Counter` — incremented by the count of
newly-created invitations on every successful `POST /:id/invite`.
- `icsServedTotal: Counter` — incremented on every successful
`GET /:id/ics` response. Labelled by `with` (`"none"` or `"rsvps"`).
- `icsFailedTotal: Counter` — incremented on every error response
served by the ICS route. Labelled by `reason` (`"not_found"`,
`"forbidden"`, `"unauthenticated"`, or `"invalid_query"`).
### `getEventsInstruments(): EventsInstruments`
Lazily build and cache the instrument bundle. Repeated calls return
the same object reference.
### `resetEventsInstruments(): void`
Drop the cached bundle. The next `getEventsInstruments()` call
rebinds against whatever `MeterProvider` is in effect.
## Implementation Notes
The bundle uses singleton caching because OTel `Counter` objects are
themselves bound to the meter that created them — calling
`meter.createCounter("events.created_total")` twice does not produce a
new metric, but it does produce a new JavaScript object that is
slightly more expensive than the cached reference. The lazy
construction also lets the polyfill in `tests/setup/polyfills.ts`
install a `MeterProvider` before any source module that touches
metrics imports is loaded, which is necessary because OTel binds the
meter to the active provider at counter-creation time.

View File

@@ -0,0 +1,112 @@
# plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `createEventsPlugin`, the factory that returns the
`PluginDescriptor<EventsPluginConfig>` consumed by `@suaveplan/api`'s
plugin registry. The factory Zod-validates options at the boundary,
constructs the runtime closure, and returns the four lifecycle hooks
(`onInit`, `onReady`, `onClose`) that the registry's topological
sorter wires up after the `database` dependency has booted.
The lifecycle is intentionally split: `onInit` reads the `db` slot
and registers the two repositories so downstream plugins (search,
audit, notifications) can resolve `eventRepository` /
`rsvpRepository` during their own `onInit`; `onReady` is the latest
phase the registry runs, so the `app` slot is guaranteed-populated
and the route mount is the last side effect before the host starts
listening.
## Features
- Zod-validated config at construction time — invalid options raise
`EventsPluginError(INVALID_CONFIG)` before the descriptor leaves
the factory.
- Lifecycle dedup — `onReady` refuses to mount when the configured
`basePath` already has routes registered, raising
`EventsPluginError(ROUTE_PREFIX_COLLISION)` instead of silently
shadowing the first mount.
- Forwards typed route events to the host's shared
`ctx.eventBus` (fixes the upstream "local emitter" smell flagged in
`enterprise/api-plugin-events.md`).
- `onClose` clears repository references so the GC can collect them.
## Usage: Basic Example
```ts
import { createEventsPlugin } from "@suaveplan/api-plugin-events";
import { quickstart } from "@suaveplan/api";
import { createDatabasePlugin } from "@suaveplan/api-plugin-database";
const app = await quickstart({
plugins: [
createDatabasePlugin({
/* ... */
}),
createEventsPlugin(), // mounts under /events by default
],
});
```
## Usage: Advanced Example
```ts
import { createEventsPlugin } from "@suaveplan/api-plugin-events";
const plugin = createEventsPlugin({
basePath: "/v1/events",
enableNotifications: false,
maxInvitesPerRequest: 25,
});
// Inspect the descriptor before handing it to the registry
console.log(plugin.name); // "events"
console.log(plugin.dependencies); // ["database"]
console.log(plugin.defaults.maxInvitesPerRequest); // 25
// Subscribe to lifecycle and domain events on the shared bus:
host.eventBus.on("events:ready", () => {
/* routes are now mounted */
});
host.eventBus.on("event:created", ({ eventId, creatorId }) => {
/* fan out to notifications, search index, audit log */
});
```
## API Reference
### `createEventsPlugin(options?: EventsPluginConfigInput): PluginDescriptor<EventsPluginConfig>`
Builds the plugin descriptor.
- **options** — Partial `EventsPluginConfig`. Each field has a
default; passing `{}` (or no argument) produces the default
config (`/events`, `enableNotifications: false`,
`maxInvitesPerRequest: 50`).
- **throws** `EventsPluginError(INVALID_CONFIG)` if `options` fails
`EventsPluginOptionsSchema`.
### Lifecycle hooks (attached to the returned descriptor)
- `onInit(ctx)` — pulls `db` from the registry, constructs the
repositories, registers `eventRepository` and `rsvpRepository`,
emits `events:initialized`.
- `onReady(ctx)` — pulls `app`, builds routes via
`createEventRoutes`, mounts under `config.basePath`, emits
`events:ready`.
- `onClose()` — clears repository refs.
## Implementation Notes
The plugin's `dependencies: ["database"]` declaration makes the
suaveplan registry topo-sort the events plugin strictly after the
database plugin. Without that ordering, `onInit` would fail because
`ctx.getService("db")` would resolve to `undefined`. The `app` slot
does not need to be declared as a dependency because every host
seeds it during registry construction (per the foundation patch
shipped on `2026-05-17`); the runtime check inside `onReady` is
defensive against custom hosts that don't pre-seed.

View File

@@ -0,0 +1,120 @@
# event-repository
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
SurrealDB-backed implementation of the `IEventRepository` contract.
The repository owns event-entity CRUD: list / get / create / update /
delete, plus the auxiliary `isCreator` ownership check and the
bucketed `updateAttendeeCount` counter mutation that the RSVP routes
call after a successful upsert. Every SurrealQL statement is
parameterised with positional `$p0`, `$p1`, … placeholders to ride
over the `DatabaseAdapter.query(sql, params: readonly unknown[])`
surface which converts the array into the `{p0, p1, …}` record shape
the surrealdb.js driver expects.
The class also handles cross-shape creator id extraction: SurrealDB
record links can deserialise as a `RecordId`-like object
(`{ tb, id }`), a bare string (`user:1`), or an object with only an
`id` property (depending on how the row was inserted); the
`extractCreatorId` private method normalises all three forms.
## Features
- 7-method surface mirroring the upstream parity: `findAll`,
`findById`, `create`, `update`, `delete`, `isCreator`,
`updateAttendeeCount`.
- Idempotent ID normalisation — every method accepts bare ids
(`abc`) and prefixed ids (`event:abc`) interchangeably.
- Pagination (`limit`, `offset`) and filtering (`status`, `upcoming`,
`past`) on `findAll`.
- Bucketed attendee counters — `updateAttendeeCount` interpolates the
arithmetic literally (SurrealDB forbids bound vars on the right of
`field + X`) but sanitises the increment to an integer via
`Math.trunc(Number.isFinite(...))`.
- Dedicated error codes — `EVENT_CREATE_FAILED` /
`EVENT_UPDATE_FAILED` raised when the adapter returns no row.
## Usage: Basic Example
```ts
import { EventRepository } from "@suaveplan/api-plugin-events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
const db = new SurrealAdapter({
/* ... */
});
await db.connect();
const repo = new EventRepository({ db });
const event = await repo.create("user:1", {
title: "Bun launch party",
start_time: "2026-06-01T18:00:00Z",
end_time: "2026-06-01T22:00:00Z",
timezone: "UTC",
});
console.log(event.id); // "event:..."
```
## Usage: Advanced Example
```ts
import { EventRepository } from "@suaveplan/api-plugin-events";
const repo = new EventRepository({ db });
// Filtered listing with explicit pagination.
const upcoming = await repo.findAll({
upcoming: true,
limit: 50,
offset: 0,
});
// Ownership check before allowing a mutation.
const owns = await repo.isCreator("event:1", "user:1");
if (!owns) throw new Error("403 — not creator");
// Bucketed counter bump after a successful RSVP.
await repo.updateAttendeeCount("event:1", "going", 1);
```
## API Reference
### `class EventRepository implements IEventRepository`
Constructor: `new EventRepository({ db: SurrealAdapter })`.
- `findAll(query?: EventQuery): Promise<Event[]>` — paginated list.
- `findById(id: string): Promise<Event | null>` — `null` when missing.
- `create(userId: string, data: CreateEvent): Promise<Event>` —
throws `EVENT_CREATE_FAILED` on empty adapter response.
- `update(id: string, data: UpdateEvent): Promise<Event>` —
throws `EVENT_UPDATE_FAILED` on empty adapter response.
- `delete(id: string): Promise<void>` — issues `DELETE event:id`.
- `isCreator(eventId: string, userId: string): Promise<boolean>`.
- `updateAttendeeCount(eventId, status, increment): Promise<void>` —
no-op for `"not_going"`.
### `interface IEventRepository`
Public contract — see `event-repository.types.ts`.
### `interface EventRepositoryConfig`
`{ db: SurrealAdapter }`.
## Implementation Notes
The `updateAttendeeCount` method intentionally interpolates the
integer increment rather than binding it as a positional parameter
because SurrealDB does not currently allow `$var` on the right-hand
side of an in-place arithmetic update (`field = field + $var`). The
method sanitises the value with `Math.trunc(Number.isFinite(...))`
so an accidental NaN / Infinity / decimal does not break the SET
clause. SurrealDB's record-link cell can deserialise as one of three
shapes depending on driver version and how the row was inserted; the
private `extractCreatorId` enumerates all three so the
`isCreator` check survives format drift.

View File

@@ -0,0 +1,115 @@
# rsvp-repository
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
SurrealDB-backed implementation of the `IRsvpRepository` contract.
The repository owns RSVP / invitation persistence: per-user
`findByEventAndUser`, paginated `findAttendees`, atomic `upsert`,
`delete` (returns the prior status), idempotent `createInvitations`,
and `countByStatus`. The route layer consumes this surface plus
`EventRepository.updateAttendeeCount` to keep the bucketed counters
on the parent event row in sync with the RSVP rows.
Like `EventRepository`, every SurrealQL statement uses positional
`$p0`-style placeholders to ride over the `DatabaseAdapter`
surface. The `(event, user)` uniqueness constraint is enforced at
the database layer via the `event_rsvp_unique_idx` UNIQUE index
declared in `surql/tables/event.surql`; the repository's `upsert`
performs an explicit `findByEventAndUser` first to discriminate
between CREATE and UPDATE paths so the route layer can compute the
old-status delta for attendee-count bookkeeping.
## Features
- 6-method surface mirroring the upstream parity.
- Idempotent ID normalisation — bare and prefixed ids both work.
- `createInvitations` skips users that already have an RSVP row so
resending an invite list never produces duplicates.
- Dedicated error codes — `RSVP_CREATE_FAILED` /
`RSVP_UPDATE_FAILED` for adapter-returns-empty cases.
- `countByStatus` falls back through `total` → `count` → `0` to
survive SurrealDB v2-vs-v3 result shape differences.
## Usage: Basic Example
```ts
import { RsvpRepository } from "@suaveplan/api-plugin-events";
const repo = new RsvpRepository({ db });
const rsvp = await repo.upsert("event:1", "user:1", { status: "going" });
console.log(rsvp.status); // "going"
```
## Usage: Advanced Example
```ts
import { RsvpRepository } from "@suaveplan/api-plugin-events";
const repo = new RsvpRepository({ db });
// Build a paginated attendee dashboard
const attendees = await repo.findAttendees("event:1", {
status: "going",
limit: 100,
offset: 0,
});
// Send a bulk invite — already-RSVPed users are skipped
const invitations = await repo.createInvitations("event:1", [
"user:2",
"user:3",
"user:4",
]);
console.log(invitations.length); // ≤ 3, depending on existing rows
// Cancel and capture the old status for an attendee-count rebalance
const oldStatus = await repo.delete("event:1", "user:2");
if (oldStatus !== null) {
// forward to EventRepository.updateAttendeeCount(..., -1)
}
// Counters
const going = await repo.countByStatus("event:1", "going");
```
## API Reference
### `class RsvpRepository implements IRsvpRepository`
Constructor: `new RsvpRepository({ db: SurrealAdapter })`.
- `findByEventAndUser(eventId, userId): Promise<Rsvp | null>`.
- `findAttendees(eventId, query?: AttendeeQuery): Promise<Attendee[]>`.
- `upsert(eventId, userId, data: CreateRsvp): Promise<Rsvp>` —
throws `RSVP_CREATE_FAILED` or `RSVP_UPDATE_FAILED` on empty
adapter response.
- `delete(eventId, userId): Promise<RsvpStatus | null>` — returns
the prior status, `null` when no row existed.
- `createInvitations(eventId, userIds: string[]): Promise<Rsvp[]>` —
returns only newly-created rows.
- `countByStatus(eventId, status: RsvpStatus): Promise<number>`.
### `interface IRsvpRepository`
Public contract — see `rsvp-repository.types.ts`.
### `interface RsvpRepositoryConfig`
`{ db: SurrealAdapter }`.
## Implementation Notes
`createInvitations` iterates the `userIds` list serially rather than
batching into a single SurrealQL statement because the
"already-invited?" check needs to be observable per-user, and
SurrealDB's `RELATION` / `INSERT INTO ... ON CONFLICT` is still
maturing in v3. The cost is N+1 queries for an invite of N users —
acceptable inside a route handler that's already guarded by
`maxInvitesPerRequest`. The `countByStatus` query uses `GROUP ALL`
to coerce SurrealDB v3's aggregation into a single-row result; v2's
implicit aggregation produced a flat row, and the dual `total ??
count ??` fallback survives both.

View File

@@ -0,0 +1,118 @@
# events-routes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the nine-route OpenAPI Hono module mounted by
`createEventsPlugin`. Each route is wrapped by `@hono/zod-openapi`'s
`createRoute` so the package's local Zod schemas drive both
input-validation and generated OpenAPI doc shape. The route layer is
the single point at which auth (`c.get("user")`), authorisation
(`eventRepository.isCreator`), `maxInvitesPerRequest` enforcement,
OTel emission, and EventBus fan-out converge.
The handlers themselves are deliberately thin: every meaningful
side-effect (CREATE row, UPDATE counter, DELETE row, EMIT event)
funnels through the two repositories and the optional `emitEvent`
callback. The callback is the integration point the plugin's
`onReady` uses to forward route events onto the shared
`ctx.eventBus` — that indirection is what fixes the upstream
"local emitter" smell flagged in `enterprise/api-plugin-events.md`.
## Features
- 9 routes: list / get / create / update / delete / rsvp /
cancel-rsvp / my-rsvp / attendees / invite.
- Zod validation on every body / param / query.
- Auth guard on every route via `c.get("user")`.
- Authorisation guard on every mutating route via
`eventRepository.isCreator`.
- `maxInvitesPerRequest` enforcement inside the `/invite` handler.
- OTel counter emission on every successful mutation.
- Typed `emitEvent` callback for cross-plugin fan-out.
## Usage: Basic Example
```ts
import { createEventRoutes } from "@suaveplan/api-plugin-events";
import { OpenAPIHono } from "@hono/zod-openapi";
const routes = createEventRoutes({
eventRepository,
rsvpRepository,
maxInvitesPerRequest: 50,
});
const parent = new OpenAPIHono();
parent.use(async (c, next) => {
c.set("user", await resolveUser(c));
await next();
});
parent.route("/events", routes);
```
## Usage: Advanced Example
```ts
import { createEventRoutes } from "@suaveplan/api-plugin-events";
const routes = createEventRoutes({
eventRepository,
rsvpRepository,
maxInvitesPerRequest: 25,
emitEvent: (event) => {
// Forward onto the host's typed bus
host.eventBus.emit(event.type, event.payload);
// Side-channel: persistent audit log
auditLog.write({
kind: event.type,
payload: event.payload,
at: new Date(),
});
},
});
// `routes` is an OpenAPIHono instance — feed it into the OpenAPI
// document generator:
host.openAPIRegistry.attach(routes);
```
## API Reference
### `createEventRoutes(config: EventRoutesConfig): OpenAPIHono`
Returns an `OpenAPIHono<{ Variables: EventsRoutesVariables }>` with
the nine routes registered.
### `interface EventRoutesConfig`
- `eventRepository: IEventRepository`
- `rsvpRepository: IRsvpRepository`
- `maxInvitesPerRequest: number`
- `emitEvent?: (event: EventsRouteEvent) => void`
### `interface EventsRoutesVariables`
`{ user: { id: string } & Record<string, unknown> | null | undefined }`
— the route handler reads `c.get("user")` from this slot; the host
supplies the auth middleware that populates it.
## Implementation Notes
The handlers manually `try { getUser(c) } catch { return c.json(...) }`
rather than relying on Hono's `onError` because the OpenAPI route
spec requires every documented response status to be reachable from
the handler body. Catching `EventsPluginError(UNAUTHENTICATED)` and
returning a 401 inline keeps the spec honest. The pattern repeats
on every route — verbose, but each block is a single
3-line `try`/`catch`/`json` triple.
The `app.route("/", routes)` line in test fixtures and in the plugin
mount mirrors the parent/child Hono pattern: the parent app owns the
auth middleware, the child app owns the routes. Hono middleware
registered on the parent BEFORE the `.route()` call fires before the
child's handlers, so `c.get("user")` resolves correctly during the
child's `c.req.valid(...)` parsing phase.

View File

@@ -0,0 +1,150 @@
# ics-export
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ics-export` route module exposes `GET /:id/ics` on top of the
shared events `OpenAPIHono` mount, returning an RFC 5545
`text/calendar` document for a single event. It is the only ICS surface
the plugin ships in 0.1: calendar-feed subscriptions, push delivery,
and recurring-event expansion (RRULE) all remain deferred to follow-up
changes per the proposal's `## Out of scope` list.
The module's contract is narrow on purpose. It owns the auth /
visibility checks for `?with=rsvps`, the failure-counter mapping
(`events.ics.failed_total{reason}`), the success-counter emission, and
the `Content-Disposition: attachment` header. The actual VCALENDAR
construction is delegated wholesale to the pure
`ics-builder/ics-builder` module so byte-level tests can verify fold
and escape rules independently of route plumbing.
## Features
- `GET /:id/ics` — returns `text/calendar; charset=utf-8` with a single
`BEGIN:VCALENDAR` … `END:VCALENDAR` body containing one `VEVENT`.
- `GET /:id/ics?with=rsvps` — same body plus one `ATTENDEE` line per
RSVP row. Requires the requester to be the event creator; non-creators
receive `403`.
- `?with=<unknown>` — surfaces as `422` with
`EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` via the route module's
`defaultHook`.
- Injectable `Clock` — `DTSTAMP` is sourced from the configured clock
so tests can pin a stable instant; production defaults to `Date.now`.
- Injectable `EventsInstruments` — production binds to the global
bundle via `getEventsInstruments()`; tests pass a fresh bundle to
assert counter emission against a known `MeterProvider`.
- `Content-Disposition: attachment; filename="event-<id>.ics"` on
every success response per RFC 6266 §4 — forces calendar-app download
rather than browser inline render.
## Usage: Basic
```ts
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createIcsExportRoutes,
EventRepository,
RsvpRepository,
} from "@suaveplan/api-plugin-events";
const app = new OpenAPIHono();
app.route(
"/events",
createIcsExportRoutes({
eventRepository: new EventRepository({ db }),
rsvpRepository: new RsvpRepository({ db }),
})
);
// GET /events/event:1/ics → 200, text/calendar
```
## Usage: Advanced
Inject a `Clock` for byte-stable `DTSTAMP` and pre-build a counter
bundle against a known `MeterProvider`:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createIcsExportRoutes,
getEventsInstruments,
resetEventsInstruments,
} from "@suaveplan/api-plugin-events";
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const provider = new MeterProvider({
readers: [new PeriodicExportingMetricReader({ exporter })],
});
metrics.setGlobalMeterProvider(provider);
resetEventsInstruments();
const app = new OpenAPIHono();
app.route(
"/events",
createIcsExportRoutes({
eventRepository,
rsvpRepository,
clock: () => Date.parse("2026-05-20T12:00:00Z"),
instruments: getEventsInstruments(),
})
);
```
## API Reference
### `createIcsExportRoutes(config: IcsExportRoutesConfig): OpenAPIHono`
Build a route module registering `GET /:id/ics`. Returns a fresh
`OpenAPIHono` instance the caller mounts on a parent app.
- `config.eventRepository: IEventRepository` — used for `findById` +
`isCreator`.
- `config.rsvpRepository: IRsvpRepository` — used for `findAttendees`
on the `?with=rsvps` variant.
- `config.clock?: Clock` — wall-clock source for `DTSTAMP`. Default:
`() => Date.now()`.
- `config.instruments?: EventsInstruments` — pre-bound counter bundle.
Default: lazy `getEventsInstruments()`.
### Response surface
| Status | Body shape | Counter |
|---|---|---|
| `200` | `text/calendar; charset=utf-8` VCALENDAR document | `events.ics.served_total{with=none\|rsvps}` |
| `401` | `{ error, code: "EVENTS_PLUGIN.UNAUTHENTICATED" }` | `events.ics.failed_total{reason=unauthenticated}` |
| `403` | `{ error, code: "EVENTS_PLUGIN.FORBIDDEN" }` | `events.ics.failed_total{reason=forbidden}` |
| `404` | `{ error, code: "EVENTS_PLUGIN.EVENT_NOT_FOUND" }` | `events.ics.failed_total{reason=not_found}` |
| `422` | `{ error, code: "EVENTS_PLUGIN.ICS_INVALID_QUERY" }` | `events.ics.failed_total{reason=invalid_query}` |
## Implementation Notes
- **Why a separate route module?** The shipped `events-routes` already
carries nine endpoints and a large `Variables` surface. Adding a
tenth route would push the file past the 300-line hard cap and
conflate the JSON-shaped route family with the binary text-stream
ICS endpoint. The split keeps both modules under the file-size
budget and lets the ICS module own its own `defaultHook` without
affecting the other handlers.
- **No `c.json` for the 200 path.** ICS bodies are not JSON. The route
uses `c.body(...)` with an explicit `Content-Type: text/calendar`
header so calendar applications detect the payload correctly.
- **422 is raised by the `defaultHook` on validation failure.** Hono's
default behaviour is `400`; we override to `422` because the request
reached a real handler but carries malformed query parameters —
semantically "unprocessable entity" rather than "malformed request".
- **Why no rate-limit / audit.** Both are scoped out at the plugin
level pending `api-plugin-rate-limit` and `api-plugin-audit`; the
ICS endpoint will inherit composition once those packages land.

View File

@@ -0,0 +1,105 @@
# event-schemas
## Purpose
The package-local Zod schemas for the `event` entity surface. Owns
every input / output shape touching the events routes and
repositories: the persisted `EventSchema`, the create / update
request bodies, the list-query parameters, the path param shape,
the `EventLocationSchema` value object, and the `EventStatusSchema`
0..2 integer encoding. The `@suaveplan/api-schemas` package
ships only event-bus envelope schemas (`DomainEvent`,
`EventEnvelope`, `EventMetadata`); the event-management entity
schemas are owned here because no other package needs them.
The schemas double as the type sources — every exported `*Schema`
has a matching `type Foo = z.infer<typeof FooSchema>` so consumers
get a single source of truth for both runtime validation and
compile-time narrowing. The `CreateEventSchema` and
`UpdateEventSchema` carry a `.refine()` constraint that asserts
`end_time > start_time` at the parser boundary so the repository
never sees a malformed time pair.
## Features
- 11 schemas spanning persistence, request, response, and value
objects.
- `CreateEventSchema` / `UpdateEventSchema` enforce `end_time > start_time`
via `.refine()`.
- `EventStatusSchema` constrains status to `0..2` (draft / active /
cancelled) matching the upstream encoding.
- `EventQuerySchema` coerces query-string `limit` / `offset` /
`upcoming` / `past` to typed numbers / booleans.
- `EventLocationSchema` validates `(lat, lng)` coordinates against
geographic bounds.
## Usage: Basic Example
```ts
import { CreateEventSchema, EventSchema } from "@suaveplan/api-plugin-events";
const parsed = CreateEventSchema.parse({
title: "Meetup",
start_time: "2026-06-01T18:00:00Z",
end_time: "2026-06-01T22:00:00Z",
});
console.log(parsed.timezone); // "UTC"
```
## Usage: Advanced Example
```ts
import {
EventQuerySchema,
EventListResponseSchema,
UpdateEventSchema,
} from "@suaveplan/api-plugin-events";
// Coerce from URL search params
const url = new URL("https://api.example.com/events?limit=10&past=true");
const query = EventQuerySchema.parse(Object.fromEntries(url.searchParams));
// Build a typed response payload
const body = EventListResponseSchema.parse({
data: [
/* events from repository */
],
total: 42,
});
// Forbid time inversion at the boundary
const result = UpdateEventSchema.safeParse({
start_time: "2026-06-01T22:00:00Z",
end_time: "2026-06-01T18:00:00Z",
});
if (!result.success) {
// result.error.issues[0].path === ["end_time"]
}
```
## API Reference
- `EventStatusSchema: z.ZodNumber` — integer 0..2.
- `EventLocationSchema: z.ZodObject` — `{ name, address?, coordinates? }`.
- `AttendeeCountSchema: z.ZodObject` — `{ going, maybe, invited }`.
- `EventSchema: z.ZodObject` — the persisted event row.
- `CreateEventSchema: z.ZodEffects<...>` — POST `/` body.
- `UpdateEventSchema: z.ZodEffects<...>` — PUT `/{id}` body.
- `EventQuerySchema: z.ZodObject` — GET `/` query params.
- `EventIdParamSchema: z.ZodObject` — `{ id: string }`.
- `EventListResponseSchema: z.ZodObject` — GET `/` response.
Every schema exports a sibling `type Foo = z.infer<typeof FooSchema>`.
## Implementation Notes
`status` is encoded as an integer (`0=draft`, `1=active`,
`2=cancelled`) rather than a string enum to match the upstream
SurrealDB schema and to keep label cardinality low when emitted as
OTel attributes. The two cross-field `.refine()` rules
(`end_time > start_time`) are duplicated on `CreateEventSchema` and
`UpdateEventSchema` rather than factored to a shared helper because
the `UpdateEventSchema` variant must tolerate a single-field update
(`{ title }`) without raising the refinement, which means the rule
fires only when both `start_time` and `end_time` appear in the same
payload.

View File

@@ -0,0 +1,101 @@
# options
## Purpose
The Zod schema for `createEventsPlugin`'s options bundle. Three
fields: `basePath` (mount prefix, default `/events`),
`enableNotifications` (reserved for a future
`@suaveplan/notification-channel` binding, default `false`),
`maxInvitesPerRequest` (runtime cap on the `/invite` endpoint,
default `50`, range `1..100`).
The schema is the boundary between caller input and the resolved
config that flows through every lifecycle hook. The plugin factory
calls `EventsPluginOptionsSchema.safeParse(options)` at construction
time and raises `EventsPluginError(INVALID_CONFIG)` on failure so an
invalid `basePath` or out-of-range `maxInvitesPerRequest` fails
before the descriptor leaves the factory and never reaches the
registry.
## Features
- Every field has a default — `EventsPluginOptionsSchema.parse({})`
produces the canonical resolved config.
- `basePath` is a non-empty string.
- `maxInvitesPerRequest` is an integer in `[1, 100]`.
- `EventsPluginConfigInput` (the `z.input` type) is partial-by-default
for the caller; `EventsPluginConfig` (the `z.infer` type) is
fully-populated for the runtime.
## Usage: Basic Example
```ts
import {
EventsPluginOptionsSchema,
type EventsPluginConfig,
} from "@suaveplan/api-plugin-events";
const config: EventsPluginConfig = EventsPluginOptionsSchema.parse({});
console.log(config.basePath); // "/events"
```
## Usage: Advanced Example
```ts
import {
EventsPluginOptionsSchema,
type EventsPluginConfigInput,
} from "@suaveplan/api-plugin-events";
function deriveConfig(env: Record<string, string | undefined>) {
const input: EventsPluginConfigInput = {
basePath: env.EVENTS_BASE_PATH ?? "/events",
enableNotifications: env.EVENTS_NOTIFY === "true",
maxInvitesPerRequest: env.EVENTS_INVITE_CAP
? Number.parseInt(env.EVENTS_INVITE_CAP, 10)
: 50,
};
const result = EventsPluginOptionsSchema.safeParse(input);
if (!result.success) {
throw new Error(
`events plugin misconfigured: ${result.error.message}`
);
}
return result.data;
}
```
## API Reference
### `EventsPluginOptionsSchema: z.ZodObject`
- `basePath: z.ZodDefault<z.ZodString>` — default `/events`.
- `enableNotifications: z.ZodDefault<z.ZodBoolean>` — default `false`.
- `maxInvitesPerRequest: z.ZodDefault<z.ZodNumber>` — int `[1, 100]`,
default `50`.
### `type EventsPluginConfig`
`z.infer<typeof EventsPluginOptionsSchema>` — the resolved, defaults-
applied shape passed to lifecycle hooks.
### `type EventsPluginConfigInput`
`z.input<typeof EventsPluginOptionsSchema>` — the caller-facing
partial shape accepted by `createEventsPlugin`.
## Implementation Notes
`enableNotifications` is reserved for the future
`@suaveplan/notification-channel` binding (see `## Out of scope` in
the proposal). The flag is validated and threaded through the
resolved config today so consumers can opt-in without a breaking
change later. The schema enforces the type contract; the binding
itself lands when the notification-channel package migrates.
`maxInvitesPerRequest` is intentionally capped at the schema layer
(100) below the body schema's hard cap (`InviteUsersSchema.user_ids`
max 100) so the route handler can return a structured
`INVITE_LIMIT_EXCEEDED` error instead of letting Zod raise a more
opaque `Array must contain at most 100 element(s)` validation
failure. The two caps coexist by design.

View File

@@ -0,0 +1,99 @@
# rsvp-schemas
## Purpose
Local Zod schemas for the RSVP / invitation surface. Owns the
persisted `RsvpSchema`, the `CreateRsvpSchema` request body for
`POST /:id/rsvp`, the `InviteUsersSchema` body for
`POST /:id/invite`, the `AttendeeQuerySchema` for the attendee
listing endpoint, and the joined `AttendeeSchema` that combines the
RSVP row with optional sparse user fields (`user_name`,
`user_email`, `user_image`).
The RSVP status is a four-value string enum (`invited`, `going`,
`maybe`, `not_going`) — not the integer encoding used for event
status — because RSVPs are read-heavy and the integer encoding gave
no real-world benefit when the upstream surface was originally
specced. The `InviteUsersSchema` enforces a 1..100 length range as
the schema-level hard cap; the runtime cap (default 50) is enforced
separately inside the `POST /:id/invite` handler so deployments can
narrow without recompiling.
## Features
- 9 schemas spanning persistence, request, response, and pagination.
- `RsvpStatusSchema` constrains status to the four documented values.
- `InviteUsersSchema` enforces `1 <= length <= 100` on `user_ids`
with non-empty-string members.
- `AttendeeSchema` extends `RsvpSchema` with optional sparse user
fields for joined listing endpoints.
- `AttendeeQuerySchema` coerces query-string parameters.
- `RsvpResponseSchema` carries a nullable `rsvp` field for
"no current RSVP" representations.
## Usage: Basic Example
```ts
import { CreateRsvpSchema, RsvpStatusSchema } from "@suaveplan/api-plugin-events";
const body = CreateRsvpSchema.parse({ status: "going" });
const isValid = RsvpStatusSchema.safeParse("yes").success; // false
```
## Usage: Advanced Example
```ts
import {
AttendeeListResponseSchema,
AttendeeQuerySchema,
InviteResponseSchema,
InviteUsersSchema,
} from "@suaveplan/api-plugin-events";
// Validate an invite body, then enforce the runtime cap separately
const parsed = InviteUsersSchema.parse({ user_ids: ["user:1", "user:2"] });
if (parsed.user_ids.length > config.maxInvitesPerRequest) {
return res.status(400).json({ error: "INVITE_LIMIT_EXCEEDED" });
}
// Build a paginated attendee response
const query = AttendeeQuerySchema.parse({ status: "going", limit: "100" });
const response = AttendeeListResponseSchema.parse({
data: [
/* joined attendees */
],
total: 42,
});
```
## API Reference
- `RsvpStatusSchema: z.ZodEnum<["invited", "going", "maybe", "not_going"]>`.
- `RsvpSchema: z.ZodObject` — persisted RSVP row.
- `CreateRsvpSchema: z.ZodObject` — `POST /:id/rsvp` body.
- `InviteUsersSchema: z.ZodObject` — `POST /:id/invite` body
(`user_ids: string[]`, length 1..100, non-empty members).
- `AttendeeQuerySchema: z.ZodObject` — pagination + status filter.
- `AttendeeSchema: z.ZodObject` — RSVP + sparse user fields.
- `AttendeeListResponseSchema: z.ZodObject` — `{ data, total }`.
- `RsvpResponseSchema: z.ZodObject` — `{ rsvp: RsvpSchema | null }`.
- `InviteResponseSchema: z.ZodObject` — `{ invitations, total }`.
Every schema exports a matching `type Foo = z.infer<typeof FooSchema>`.
## Implementation Notes
The schema-level cap on `InviteUsersSchema.user_ids` is 100 and the
runtime cap (`config.maxInvitesPerRequest`) defaults to 50. The two
limits coexist because the schema-level cap protects against
abusive payloads (10 000-element arrays that would still parse
correctly but exhaust the server) and the runtime cap encodes a
per-deployment policy. The route handler enforces the runtime cap
after the schema parse so the response code is `400` with
`EVENTS_PLUGIN.INVITE_LIMIT_EXCEEDED`, not `422`/`400` from Zod.
`AttendeeSchema` extends `RsvpSchema` with optional joined columns
because the suaveplan SurrealDB driver doesn't materialise FETCH
expansions into nested objects by default — the route handler reads
the raw projection from `findAttendees` which mixes RSVP fields with
optional joined user fields.

View File

@@ -0,0 +1,109 @@
# registry-augmentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Module-augmentation file that adds the `eventRepository` and
`rsvpRepository` slots to `@suaveplan/api-plugin/service-registry`'s
`ServiceRegistry` interface. Importing this file for its side
effects — which the package root barrel does automatically — makes
`ctx.getService("eventRepository")` resolve to
`IEventRepository | undefined` for any downstream plugin's type
checker without a cast.
The file also re-declares the host-owned `app` and `db` slots so
this plugin's `onInit` / `onReady` can resolve them with full typing
even in consumer projects that have not explicitly imported
`@suaveplan/api`. TypeScript merges duplicate `declare module`
blocks, so the upstream `@suaveplan/api`'s authoritative declaration
remains in effect for consumers that import it directly — this file
is purely additive.
## Features
- Adds `eventRepository: IEventRepository` and
`rsvpRepository: IRsvpRepository` slots.
- Re-declares `app: OpenAPIHono` and `db?: SurrealAdapter` for
self-contained type resolution.
- Zero runtime cost — `declare module` produces no JavaScript output.
- Cumulative — does not conflict with `@suaveplan/api`'s own
augmentation.
## Usage: Basic Example
```ts
// Side-effect import — the package root barrel does this for you,
// but explicit imports are also supported.
import "@suaveplan/api-plugin-events";
import type { PluginContext } from "@suaveplan/api-plugin";
function inspect(ctx: PluginContext) {
const repo = ctx.getService("eventRepository");
// `repo` is typed as `IEventRepository | undefined`
}
```
## Usage: Advanced Example
```ts
import type { PluginContext } from "@suaveplan/api-plugin";
import "@suaveplan/api-plugin-events"; // load the augmentation
export function createSearchSyncPlugin() {
return {
name: "search-sync",
dependencies: ["events"],
async onReady(ctx: PluginContext) {
const events = ctx.getService("eventRepository");
if (events === undefined) {
throw new Error("eventRepository not available");
}
// Wire a search-index sync against the typed surface
ctx.eventBus.on("event:created", async ({ eventId }) => {
const event = await events.findById(eventId);
if (event !== null) await searchIndex.index(event);
});
},
};
}
```
## API Reference
This file exports no runtime values. The shape it produces is:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
eventRepository: IEventRepository;
rsvpRepository: IRsvpRepository;
app: OpenAPIHono;
db?: SurrealAdapter;
}
}
```
Consumers reach the augmented type through any of:
- `import "@suaveplan/api-plugin-events"` (package root barrel —
loads the augmentation as a side effect).
- `import "@suaveplan/api-plugin-events/services/registry-augmentation"`
if a consumer wants the augmentation without pulling in the rest
of the package.
## Implementation Notes
The pattern is identical to `@suaveplan/api-plugin-search`'s
`services/registry-augmentation.ts` — every plugin owns the slots it
contributes, and the host-owned slots are re-declared cumulatively
so the plugin's own source files type-check without forcing a
consumer to import `@suaveplan/api` first. Cumulative module
augmentation is a non-negotiable feature of TypeScript and survives
both `vite-plugin-dts` and the canonical `tsc --build` pipeline.
The `db` slot is declared optional (`db?: SurrealAdapter`) to match
the upstream `@suaveplan/api` declaration; this plugin's `onInit`
treats `undefined` as the `SERVICE_NOT_AVAILABLE` failure case.

View File

@@ -0,0 +1,109 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type definitions that don't live alongside their schemas or
implementations: the cross-plugin `EventsPluginEvents` map (the
typed payloads emitted on the shared `ctx.eventBus`), the
`EventsRouteEvent` discriminated union (the payload shape the route
handlers pass into the optional `emitEvent` callback), and the
`EventRoutesConfig` interface consumed by `createEventRoutes`.
`EventsPluginEvents` is the contract other plugins subscribe to.
Lifecycle topics (`events:initialized`, `events:ready`) carry no
payload; domain topics (`event:created`, `event:updated`,
`event:cancelled`, `rsvp:created`, `rsvp:cancelled`,
`event:invitations:sent`) carry strongly-typed payloads. The
upstream package wired these payloads through a private
`EventEmitter<EventPluginEventMap>` — the port lifts them
onto the host's shared bus so siblings (audit, notifications,
search-sync) can subscribe without poking at the events plugin's
internals.
## Features
- `EventsPluginEvents` — the canonical event-map for the plugin.
- `EventsRouteEvent` — discriminated union of route-side emissions.
- `EventRoutesConfig` — dependency surface of `createEventRoutes`.
- Zero runtime cost — types only.
## Usage: Basic Example
```ts
import type { EventsPluginEvents } from "@suaveplan/api-plugin-events";
function onEventCreated(payload: EventsPluginEvents["event:created"]) {
console.log(payload.eventId, payload.creatorId);
}
```
## Usage: Advanced Example
```ts
import type {
EventRoutesConfig,
EventsRouteEvent,
} from "@suaveplan/api-plugin-events";
import { createEventRoutes } from "@suaveplan/api-plugin-events";
const config: EventRoutesConfig = {
eventRepository,
rsvpRepository,
maxInvitesPerRequest: 50,
emitEvent: (event: EventsRouteEvent) => {
switch (event.type) {
case "event:created":
metrics.add("events", 1);
break;
case "rsvp:created":
metrics.add("rsvps", 1, { status: event.payload.status });
break;
default:
break;
}
},
};
const routes = createEventRoutes(config);
```
## API Reference
### `interface EventsPluginEvents`
The event-map for the plugin. Eight keys:
- `events:initialized: undefined`
- `events:ready: undefined`
- `event:created: { eventId: string; creatorId: string }`
- `event:updated: { eventId: string; updatedBy: string }`
- `event:cancelled: { eventId: string; cancelledBy: string }`
- `rsvp:created: { eventId: string; userId: string; status: string }`
- `rsvp:cancelled: { eventId: string; userId: string; oldStatus: string }`
- `event:invitations:sent: { eventId: string; total: number }`
### `type EventsRouteEvent`
Discriminated union over the six domain topics. Each variant has
`type: <topic>` and `payload: EventsPluginEvents[<topic>]`.
### `interface EventRoutesConfig`
- `eventRepository: IEventRepository`
- `rsvpRepository: IRsvpRepository`
- `maxInvitesPerRequest: number`
- `emitEvent?: (event: EventsRouteEvent) => void`
## Implementation Notes
The plugin's `onReady` builds `emitEvent` as a thin forwarder onto
`ctx.eventBus.emit(event.type, event.payload)`. The `EventBus`
implementation is non-typed at the call site (the typing comes from
the global event-map augmentation), so the forwarder's narrowing
happens at the call site of `createEventRoutes` — that's why the
route handlers always pass a literal `{ type, payload }` object
instead of constructing it dynamically. The discriminated union
makes the call site exhaustive against the eight topics.