feat: add actions to FeedSource interface

Add listActions() and executeAction() to FeedSource for write
operations back to external services. Actions use arktype schemas
for input validation via StandardSchemaV1.

- ActionDefinition type with optional input schema
- FeedEngine routes actions with existence and ID validation
- Source IDs use reverse-domain format (aris.location, aris.tfl)
- LocationSource: update-location action with schema validation
- TflSource: set-lines-of-interest action with lineId validation
- No-op implementations for sources without actions

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-02-15 12:26:23 +00:00
parent 4d6cac7ec8
commit 699155e0d8
29 changed files with 1169 additions and 116 deletions

View File

@@ -1,3 +1,4 @@
import type { ActionDefinition } from "./action"
import type { Context } from "./context"
import type { FeedItem } from "./feed"
import type { FeedSource } from "./feed-source"
@@ -187,6 +188,44 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this.context
}
/**
* Execute an action on a registered source.
* Validates the action exists before dispatching.
*
* In pull-only mode (before `start()` is called), the action mutates source
* state but does not automatically refresh dependents. Call `refresh()`
* after to propagate changes. In reactive mode (`start()` called), sources
* that push context updates (e.g., LocationSource) will trigger dependent
* refresh automatically.
*/
async executeAction(sourceId: string, actionId: string, params: unknown): Promise<unknown> {
const actions = await this.listActions(sourceId)
if (!(actionId in actions)) {
throw new Error(`Action "${actionId}" not found on source "${sourceId}"`)
}
return this.sources.get(sourceId)!.executeAction(actionId, params)
}
/**
* List actions available on a specific source.
* Validates that action definition IDs match their record keys.
*/
async listActions(sourceId: string): Promise<Record<string, ActionDefinition>> {
const source = this.sources.get(sourceId)
if (!source) {
throw new Error(`Source not found: ${sourceId}`)
}
const actions = await source.listActions()
for (const [key, definition] of Object.entries(actions)) {
if (key !== definition.id) {
throw new Error(
`Action ID mismatch on source "${sourceId}": key "${key}" !== definition.id "${definition.id}"`,
)
}
}
return actions
}
private ensureGraph(): SourceGraph {
if (!this.graph) {
this.graph = buildGraph(Array.from(this.sources.values()))
@@ -240,7 +279,11 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
items.sort((a, b) => b.priority - a.priority)
this.notifySubscribers({ context: this.context, items: items as TItems[], errors })
this.notifySubscribers({
context: this.context,
items: items as TItems[],
errors,
})
}
private collectDependents(sourceId: string, graph: SourceGraph): string[] {