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

@@ -94,12 +94,12 @@ describe("TflSource", () => {
describe("interface", () => {
test("has correct id", () => {
const source = new TflSource({ client: api })
expect(source.id).toBe("tfl")
expect(source.id).toBe("aris.tfl")
})
test("depends on location", () => {
const source = new TflSource({ client: api })
expect(source.dependencies).toEqual(["location"])
expect(source.dependencies).toEqual(["aris.location"])
})
test("implements fetchItems", () => {
@@ -122,7 +122,12 @@ describe("TflSource", () => {
severity: "minor-delays",
description: "Delays",
},
{ lineId: "central", lineName: "Central", severity: "closure", description: "Closed" },
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Closed",
},
]
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
},
@@ -144,7 +149,10 @@ describe("TflSource", () => {
})
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
const source = new TflSource({ client: lineFilteringApi, lines: ["northern"] })
const source = new TflSource({
client: lineFilteringApi,
lines: ["northern"],
})
const filtered = await source.fetchItems(createContext())
expect(filtered.length).toBe(1)
@@ -164,7 +172,12 @@ describe("TflSource", () => {
test("feed items have correct base structure", async () => {
const source = new TflSource({ client: api })
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
const location: Location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const items = await source.fetchItems(createContext(location))
for (const item of items) {
@@ -178,7 +191,12 @@ describe("TflSource", () => {
test("feed items have correct data structure", async () => {
const source = new TflSource({ client: api })
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
const location: Location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const items = await source.fetchItems(createContext(location))
for (const item of items) {
@@ -230,7 +248,12 @@ describe("TflSource", () => {
test("closestStationDistance is number when location provided", async () => {
const source = new TflSource({ client: api })
const location: Location = { lat: 51.5074, lng: -0.1278, accuracy: 10, timestamp: new Date() }
const location: Location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const items = await source.fetchItems(createContext(location))
for (const item of items) {
@@ -248,6 +271,62 @@ describe("TflSource", () => {
}
})
})
describe("actions", () => {
test("listActions returns set-lines-of-interest", async () => {
const source = new TflSource({ client: api })
const actions = await source.listActions()
expect(actions["set-lines-of-interest"]).toBeDefined()
expect(actions["set-lines-of-interest"]!.id).toBe("set-lines-of-interest")
})
test("executeAction set-lines-of-interest updates lines", async () => {
const lineFilteringApi: ITflApi = {
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
const all: TflLineStatus[] = [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Delays",
},
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Closed",
},
]
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
},
async fetchStations(): Promise<StationLocation[]> {
return []
},
}
const source = new TflSource({ client: lineFilteringApi })
await source.executeAction("set-lines-of-interest", ["northern"])
const items = await source.fetchItems(createContext())
expect(items.length).toBe(1)
expect(items[0]!.data.line).toBe("northern")
})
test("executeAction throws on invalid input", async () => {
const source = new TflSource({ client: api })
await expect(
source.executeAction("set-lines-of-interest", "not-an-array"),
).rejects.toThrow()
})
test("executeAction throws for unknown action", async () => {
const source = new TflSource({ client: api })
await expect(source.executeAction("nonexistent", {})).rejects.toThrow("Unknown action")
})
})
})
describe("TfL Fixture Data Shape", () => {