Events

Events describe Plugin activity. Core validates data and distinguishes committed Actions, server observations, and browser observations. Analytics can persist selected activity.

Emit from a server Plugin

Create paths/lesson.page.md with a heading, then save this complete Plugin as plugins/entry.plugin.ts:

import { defineEvent, definePlugin, route, type PluginContext } from "@pathmx/core"
import { z } from "zod"

const Visit = defineEvent({
  data: z.object({
    code: z.string().max(160),
    destination: z.string().max(2048),
    source: z.string(),
  }),
  track: { properties: ["code", "destination"], source: "source" },
})

export default function EntryPlugin() {
  let events: PluginContext["events"]
  return definePlugin({
    id: "entry",
    events: { visit: Visit },
    setup(ctx) { events = ctx.events },
    sourceRoutes(source) {
      if (source.path !== "/lesson.page.md") return []
      return [route.redirect("/start", {
        source: source.id,
        async onRedirect({ source, destination }) {
          await events.emit(Visit, {
            code: "start",
            destination: destination.pathname,
            source: source.id,
          })
        },
      })]
    },
  })
}

A GET to /start redirects to the admitted lesson and publishes entry.visit. The Plugin works with or without Analytics. HEAD requests do not emit the visit. ctx.events.emit(definition, data) returns Promise<void> after validation and publication; it does not wait for subscriber persistence. The definition must be registered by the emitting Plugin. Core supplies the id, timestamp, and server provenance. Each Plugin factory owns its context reference.

Record an Action outcome

Inside an Action's run({ tx, events }), call events.record(definition, data) after staging the domain change. The Action recorder validates all staged Events before committing and publishes them only if the commit succeeds. Its Event definition must be registered by a Plugin. Use domain Events for actual state changes; a successful no-op need not repeat a change Event.

Core also publishes action.completed after every successful Action invocation, including successful no-ops. It contains { action: "plugin.action" } and uses the same Action origin. Its Analytics collection default is disabled; an explicit events: { "action.completed": true } override enables invocation counts.

Events share this envelope:

type EventOrigin =
  | { kind: "action"; actor: ActorId; operation: string; target?: string }
  | { kind: "server"; plugin: string }

// PathMXEvent<Data>
{ id, type, occurredAt, origin, data }

Only Action origins contain a trusted Actor and operation. Caller-provided browser fields cannot become Action provenance.

Select safe tracking properties

defineEvent({ data, track: true }) enables count-only tracking by default. A tracking object selects top-level scalar properties and, optionally, a Source field for public admission and analytics: false exclusion:

track: {
  enabled: false,
  properties: ["code", "destination"],
  source: "source",
}

Omitting enabled means true. enabled: false leaves collection off while making the declared properties available for explicit repository opt-in. An Event with no tracking declaration is count-only if explicitly enabled. Property keys are checked against the schema; objects and arrays cannot be tracking properties. There are at most 16 selected properties, with strings bounded to 2,048 characters and finite numeric values. Invalid projections skip collection and report an error.

The Source field is resolved for admission; it is not automatically a custom property. Emit canonical public paths without query strings or fragments. Choose safe fields deliberately: private responses, uploads, prompts, and freeform answers need not become Analytics properties.

Contribute from a browser interaction

Register a browser definition in the producing Plugin:

const ArtistSaved = defineEvent({
  data: z.strictObject({ source: z.string(), persisted: z.boolean() }),
  browser: true,
  track: { source: "source", properties: ["persisted"] },
})

// In a Plugin with id "guide":
events: { artist_saved: ArtistSaved }

Call this from the Plugin's browser client after one user action changes state:

import { observe } from "@pathmx/core/runtime"

observe("guide.artist_saved", {
  source: artistSourceId,
  persisted: savedToBrowserStorage,
})

observe(name, data) returns void and never throws into the interaction. Without an enabled observation sink it does nothing and does not queue for later setup. Emit at the originating state change, rather than at renderer updates or storage synchronization notifications. Each document, including a same-origin dialog, owns its own observations.

Browser definitions use strict flat objects: strings, booleans, finite numbers, scalar enums, and optional or nullable scalar fields. They require a nonoptional string Source field. String length and inclusive numeric bounds are supported; transforms, refinements, nested data, and string formats are rejected. Browser data has at most 16 fields and 4 KiB, with strings limited to 2,048 characters. These same supported bounds are checked in the browser and at server ingress. Public metadata includes enabled browser definitions without Zod or private server schemas.

A browser: true definition cannot also be recorded by an Action or server emitter. Browser observations remain anonymous and do not enter the trusted Core journal.

Subscribe and inspect

ctx.events.subscribe(listener) supplies future EventRecord[] and returns an unsubscribe function. Each record contains event, cursor, recordedAt, and an optional resolved tracking declaration. Subscriber failures are logged and isolated. Core does not await asynchronous subscribers; each consumer must bound its own pending work.

ctx.events.definitions() returns readonly { id, definition } metadata for all registered Events, including definitions with no observations. CLI inspection also provides it through (await ctx.inspect()).events.definitions().

ctx.events.records() returns the retained journal window. records(afterCursor) throws when a cursor is expired or belongs to another host process. The journal retains at most 1,000 records and 4 MiB, with a 64 KiB serialized data limit per server or Action Event. It is process-local memory; durable reporting belongs to a subscriber such as Analytics.

Durable consumers

Process-local Event subscribers do not survive server restarts. Register a durable job for work that must be admitted with the originating Action and resumed later. Trusted server and Plugin CLI ctx.events.emit also await durable admission when a job host is active. Events remain domain facts; jobs track the work caused by them.