feat: replace flat context with tuple-keyed store (#50)

Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.

Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-01 22:52:41 +00:00
committed by GitHub
parent 8ca8a0d1d2
commit 96e22e227c
29 changed files with 544 additions and 227 deletions

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<Partial<Context> | null> {
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {

View File

@@ -1,4 +1,4 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
import { LocationSource } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<Partial<Context> | null> {
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems() {

View File

@@ -1,8 +1,10 @@
import type { Context } from "./context"
import type { ContextEntry } from "./context"
import type { ContextProvider } from "./context-provider"
import { contextKey } from "./context"
interface ContextUpdatable {
pushContextUpdate(update: Partial<Context>): void
pushContextUpdate(entries: readonly ContextEntry[]): void
}
export interface ProviderError {
@@ -54,7 +56,7 @@ export class ContextBridge {
this.providers.set(provider.key, provider as ContextProvider)
const cleanup = provider.onUpdate((value) => {
this.controller.pushContextUpdate({ [provider.key]: value })
this.controller.pushContextUpdate([[contextKey(provider.key), value]])
})
this.cleanups.push(cleanup)
@@ -67,7 +69,7 @@ export class ContextBridge {
* Returns errors from providers that failed to fetch.
*/
async refresh(): Promise<RefreshResult> {
const updates: Partial<Context> = {}
const collected: ContextEntry[] = []
const errors: ProviderError[] = []
const entries = Array.from(this.providers.entries())
@@ -78,7 +80,7 @@ export class ContextBridge {
entries.forEach(([key], i) => {
const result = results[i]
if (result?.status === "fulfilled") {
updates[key] = result.value
collected.push([contextKey(key), result.value])
} else if (result?.status === "rejected") {
errors.push({
key,
@@ -87,7 +89,7 @@ export class ContextBridge {
}
})
this.controller.pushContextUpdate(updates)
this.controller.pushContextUpdate(collected)
return { errors }
}

View File

@@ -0,0 +1,184 @@
import { describe, expect, test } from "bun:test"
import type { ContextKey } from "./context"
import { Context, contextKey } from "./context"
interface Weather {
temperature: number
}
interface NextEvent {
title: string
}
const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "current")
const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
describe("Context", () => {
describe("get", () => {
test("returns undefined for missing key", () => {
const ctx = new Context()
expect(ctx.get(WeatherKey)).toBeUndefined()
})
test("returns value for exact key match", () => {
const ctx = new Context()
const weather: Weather = { temperature: 20 }
ctx.set([[WeatherKey, weather]])
expect(ctx.get(WeatherKey)).toEqual(weather)
})
test("distinguishes keys with different parts", () => {
const ctx = new Context()
ctx.set([
[WeatherKey, { temperature: 20 }],
[NextEventKey, { title: "Standup" }],
])
expect(ctx.get(WeatherKey)).toEqual({ temperature: 20 })
expect(ctx.get(NextEventKey)).toEqual({ title: "Standup" })
})
test("last write wins for same key", () => {
const ctx = new Context()
ctx.set([[WeatherKey, { temperature: 20 }]])
ctx.set([[WeatherKey, { temperature: 25 }]])
expect(ctx.get(WeatherKey)).toEqual({ temperature: 25 })
})
})
describe("find", () => {
test("returns empty array when no keys match", () => {
const ctx = new Context()
expect(ctx.find(WeatherKey)).toEqual([])
})
test("returns exact match as single result", () => {
const ctx = new Context()
ctx.set([[NextEventKey, { title: "Standup" }]])
const results = ctx.find(NextEventKey)
expect(results).toHaveLength(1)
expect(results[0]!.value).toEqual({ title: "Standup" })
})
test("prefix match returns multiple instances", () => {
const workKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "work",
})
const personalKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "personal",
})
const ctx = new Context()
ctx.set([
[workKey, { title: "Sprint Planning" }],
[personalKey, { title: "Dentist" }],
])
const prefix = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
const results = ctx.find(prefix)
expect(results).toHaveLength(2)
const titles = results.map((r) => r.value.title).sort()
expect(titles).toEqual(["Dentist", "Sprint Planning"])
})
test("prefix match includes exact match and longer keys", () => {
const baseKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
const instanceKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
account: "work",
})
const ctx = new Context()
ctx.set([
[baseKey, { title: "Base" }],
[instanceKey, { title: "Instance" }],
])
const results = ctx.find(baseKey)
expect(results).toHaveLength(2)
})
test("does not match keys that share a string prefix but differ at segment boundary", () => {
const keyA = contextKey<string>("aris.calendar", "next")
const keyB = contextKey<string>("aris.calendar", "nextEvent")
const ctx = new Context()
ctx.set([
[keyA, "a"],
[keyB, "b"],
])
const results = ctx.find(keyA)
expect(results).toHaveLength(1)
expect(results[0]!.value).toBe("a")
})
test("object key parts with different property order match", () => {
const key1 = contextKey<string>("source", "ctx", { b: 2, a: 1 })
const key2 = contextKey<string>("source", "ctx", { a: 1, b: 2 })
const ctx = new Context()
ctx.set([[key1, "value"]])
// Exact match via get should work regardless of property order
expect(ctx.get(key2)).toBe("value")
// find with the reordered key as prefix should also match
const prefix = contextKey<string>("source", "ctx")
const results = ctx.find(prefix)
expect(results).toHaveLength(1)
})
test("single-segment prefix matches all keys starting with that segment", () => {
const ctx = new Context()
ctx.set([
[contextKey("aris.weather", "current"), { temperature: 20 }],
[contextKey("aris.weather", "forecast"), { high: 25 }],
[contextKey("aris.calendar", "nextEvent"), { title: "Meeting" }],
])
const results = ctx.find(contextKey("aris.weather"))
expect(results).toHaveLength(2)
})
test("does not match shorter keys", () => {
const ctx = new Context()
ctx.set([[contextKey("aris.weather"), "short"]])
const results = ctx.find(contextKey("aris.weather", "current"))
expect(results).toHaveLength(0)
})
test("numeric key parts match correctly", () => {
const ctx = new Context()
ctx.set([
[contextKey("source", 1, "data"), "one"],
[contextKey("source", 2, "data"), "two"],
])
const results = ctx.find(contextKey("source", 1))
expect(results).toHaveLength(1)
expect(results[0]!.value).toBe("one")
})
})
describe("size", () => {
test("returns 0 for empty context", () => {
expect(new Context().size).toBe(0)
})
test("reflects number of entries", () => {
const ctx = new Context()
ctx.set([
[WeatherKey, { temperature: 20 }],
[NextEventKey, { title: "Standup" }],
])
expect(ctx.size).toBe(2)
})
})
})

View File

@@ -1,46 +1,131 @@
/**
* Branded type for type-safe context keys.
* Tuple-keyed context system inspired by React Query's query keys.
*
* Each package defines its own keys with associated value types:
* ```ts
* const LocationKey: ContextKey<Location> = contextKey("location")
* ```
* Context keys are arrays that form a hierarchy. Sources write to specific
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
* and consumers can query by exact match or prefix match to get all values
* of a given type across source instances.
*/
export type ContextKey<T> = string & { __contextValue?: T }
/**
* Creates a typed context key.
*
* @example
* ```ts
* interface Location { lat: number; lng: number; accuracy: number }
* const LocationKey: ContextKey<Location> = contextKey("location")
* ```
*/
export function contextKey<T>(key: string): ContextKey<T> {
return key as ContextKey<T>
// -- Key types --
/** A single segment of a context key: string, number, or a record of primitives. */
export type ContextKeyPart = string | number | Record<string, unknown>
/** A context key is a readonly tuple of parts, branded with the value type. */
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
/** Creates a typed context key. */
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
return parts as ContextKey<T>
}
/**
* Type-safe accessor for context values.
*
* @example
* ```ts
* const location = contextValue(context, LocationKey)
* if (location) {
* console.log(location.lat, location.lng)
* }
* ```
*/
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
return context[key] as T | undefined
}
// -- Serialization --
/**
* Arbitrary key-value bag representing the current state.
* Always includes `time`. Other keys are added by context providers.
* Deterministic serialization of a context key for use as a Map key.
* Object parts have their keys sorted for stable comparison.
*/
export interface Context {
export function serializeKey(key: readonly ContextKeyPart[]): string {
return JSON.stringify(key, (_key, value) => {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const sorted: Record<string, unknown> = {}
for (const k of Object.keys(value).sort()) {
sorted[k] = value[k]
}
return sorted
}
return value
})
}
// -- Key matching --
/** Returns true if `key` starts with all parts of `prefix`. */
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
if (key.length < prefix.length) return false
for (let i = 0; i < prefix.length; i++) {
if (!partsEqual(key[i]!, prefix[i]!)) return false
}
return true
}
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
function partsEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== typeof b) return false
if (a && b && typeof a === "object" && typeof b === "object") {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key) =>
partsEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
),
)
}
return false
}
// -- Context store --
/** A single context entry: a key-value pair. */
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
/**
* Mutable context store with tuple keys.
*
* Supports exact-match lookups and prefix-match queries.
* Sources write context in topological order during refresh.
*/
export class Context {
time: Date
[key: string]: unknown
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
constructor(time: Date = new Date()) {
this.time = time
this.store = new Map()
}
/** Merges entries into this context. */
set(entries: readonly ContextEntry[]): void {
for (const [key, value] of entries) {
this.store.set(serializeKey(key), { key, value })
}
}
/** Exact-match lookup. Returns the value for the given key, or undefined. */
get<T>(key: ContextKey<T>): T | undefined {
const entry = this.store.get(serializeKey(key))
return entry?.value as T | undefined
}
/**
* Prefix-match query. Returns all entries whose key starts with the given prefix.
*
* @example
* ```ts
* // Get all "nextEvent" values across calendar source instances
* const events = context.find(contextKey("nextEvent"))
* ```
*/
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> {
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = []
for (const entry of this.store.values()) {
if (keyStartsWith(entry.key, prefix)) {
results.push({ key: entry.key, value: entry.value as T })
}
}
return results
}
/** Returns the number of entries (excluding time). */
get size(): number {
return this.store.size
}
}

View File

@@ -12,7 +12,7 @@ import type { FeedItem } from "./feed"
* readonly type = "weather"
*
* async query(context: Context): Promise<WeatherItem[]> {
* const location = contextValue(context, LocationKey)
* const location = context.get(LocationKey)
* if (!location) return []
* const data = await fetchWeather(location)
* return [{

View File

@@ -1,8 +1,9 @@
import type { Context } from "./context"
import type { ContextEntry } from "./context"
import type { DataSource } from "./data-source"
import type { FeedItem } from "./feed"
import type { ReconcileResult } from "./reconciler"
import { Context } from "./context"
import { Reconciler } from "./reconciler"
export interface FeedControllerConfig {
@@ -40,7 +41,7 @@ const DEFAULT_DEBOUNCE_MS = 100
* })
*
* // Context update triggers debounced reconcile
* controller.pushContextUpdate({ [LocationKey]: location })
* controller.pushContextUpdate([[LocationKey, location]])
*
* // Direct reconcile (no debounce)
* const result = await controller.reconcile()
@@ -59,7 +60,7 @@ export class FeedController<TItems extends FeedItem = never> {
private stopped = false
constructor(config?: FeedControllerConfig) {
this.context = config?.initialContext ?? { time: new Date() }
this.context = config?.initialContext ?? new Context()
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
this.timeout = config?.timeout
}
@@ -94,9 +95,10 @@ export class FeedController<TItems extends FeedItem = never> {
}
}
/** Merges update into context and schedules a debounced reconcile. */
pushContextUpdate(update: Partial<Context>): void {
this.context = { ...this.context, ...update, time: new Date() }
/** Merges entries into context and schedules a debounced reconcile. */
pushContextUpdate(entries: readonly ContextEntry[]): void {
this.context.time = new Date()
this.context.set(entries)
this.scheduleReconcile()
}

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((update: Partial<Context>) => void) | null = null
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
},
async fetchContext() {
return { [LocationKey]: currentLocation }
return [[LocationKey, currentLocation]]
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.({ [LocationKey]: location })
callback?.([[LocationKey, location]])
},
}
}
@@ -85,15 +85,15 @@ function createWeatherSource(
...noActions,
async fetchContext(context) {
const location = contextValue(context, LocationKey)
const location = context.get(LocationKey)
if (!location) return null
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
return [[WeatherKey, weather]]
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
const weather = context.get(WeatherKey)
if (!weather) return []
return [
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
const weather = context.get(WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
...noActions,
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
},
}
@@ -275,9 +275,9 @@ describe("FeedEngine", () => {
...noActions,
async fetchContext(ctx) {
order.push("weather")
const loc = contextValue(ctx, LocationKey)
const loc = ctx.get(LocationKey)
expect(loc).toBeDefined()
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
},
}
@@ -298,11 +298,11 @@ describe("FeedEngine", () => {
const { context } = await engine.refresh()
expect(contextValue(context, LocationKey)).toEqual({
expect(context.get(LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
expect(contextValue(context, WeatherKey)).toEqual({
expect(context.get(WeatherKey)).toEqual({
temperature: 20,
condition: "sunny",
})
@@ -361,7 +361,7 @@ describe("FeedEngine", () => {
const { context, items } = await engine.refresh()
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(context.get(WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
@@ -459,7 +459,7 @@ describe("FeedEngine", () => {
await engine.refresh()
const context = engine.currentContext()
expect(contextValue(context, LocationKey)).toEqual({
expect(context.get(LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
@@ -734,7 +734,7 @@ describe("FeedEngine", () => {
})
test("reactive item update refreshes cache", async () => {
let itemUpdateCallback: (() => void) | null = null
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null
const source: FeedSource = {
id: "reactive-items",
@@ -765,7 +765,7 @@ describe("FeedEngine", () => {
engine.start()
// Trigger item update
itemUpdateCallback!()
itemUpdateCallback!([])
// Wait for async refresh
await new Promise((resolve) => setTimeout(resolve, 50))
@@ -885,12 +885,12 @@ describe("FeedEngine", () => {
...noActions,
async fetchContext(ctx) {
fetchCount++
const loc = contextValue(ctx, LocationKey)
const loc = ctx.get(LocationKey)
if (!loc) return null
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
},
async fetchItems(ctx) {
const weather = contextValue(ctx, WeatherKey)
const weather = ctx.get(WeatherKey)
if (!weather) return []
return [
{

View File

@@ -1,9 +1,11 @@
import type { ActionDefinition } from "./action"
import type { Context } from "./context"
import type { ContextEntry } from "./context"
import type { FeedItem } from "./feed"
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
import type { FeedSource } from "./feed-source"
import { Context } from "./context"
export interface SourceError {
sourceId: string
error: Error
@@ -65,7 +67,7 @@ interface SourceGraph {
export class FeedEngine<TItems extends FeedItem = FeedItem> {
private sources = new Map<string, FeedSource>()
private graph: SourceGraph | null = null
private context: Context = { time: new Date() }
private context: Context = new Context()
private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = []
private started = false
@@ -138,14 +140,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const errors: SourceError[] = []
// Reset context with fresh time
let context: Context = { time: new Date() }
const context = new Context()
// Run fetchContext in topological order
for (const source of graph.sorted) {
try {
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
const entries = await source.fetchContext(context)
if (entries) {
context.set(entries)
}
} catch (err) {
errors.push({
@@ -213,8 +215,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
for (const source of graph.sorted) {
if (source.onContextUpdate) {
const cleanup = source.onContextUpdate(
(update) => {
this.handleContextUpdate(source.id, update)
(entries) => {
this.handleContextUpdate(source.id, entries)
},
() => this.context,
)
@@ -365,8 +367,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this.graph
}
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
this.context = { ...this.context, ...update, time: new Date() }
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void {
this.context.time = new Date()
this.context.set(entries)
// Re-run dependents and notify
this.refreshDependents(sourceId)
@@ -381,9 +384,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
const source = graph.sources.get(id)
if (source) {
try {
const update = await source.fetchContext(this.context)
if (update) {
this.context = { ...this.context, ...update }
const entries = await source.fetchContext(this.context)
if (entries) {
this.context.set(entries)
}
} catch {
// Errors during reactive updates are logged but don't stop propagation

View File

@@ -1,6 +1,12 @@
import { describe, expect, mock, test } from "bun:test"
import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
import type {
ActionDefinition,
ContextEntry,
FeedItem,
FeedPostProcessor,
FeedSource,
} from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError } from "./index"
@@ -471,7 +477,7 @@ describe("FeedPostProcessor", () => {
test("post-processors run during reactive context updates", async () => {
let callCount = 0
let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
@@ -502,7 +508,7 @@ describe("FeedPostProcessor", () => {
const countAfterStart = callCount
// Trigger a reactive context update
triggerUpdate!({ foo: "bar" })
triggerUpdate!([])
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
@@ -513,7 +519,7 @@ describe("FeedPostProcessor", () => {
test("post-processors run during reactive item updates", async () => {
let callCount = 0
let triggerItemsUpdate: (() => void) | null = null
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
@@ -543,7 +549,7 @@ describe("FeedPostProcessor", () => {
const countAfterStart = callCount
// Trigger a reactive items update
triggerItemsUpdate!()
triggerItemsUpdate!([weatherItem("w1", 25)])
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)

View File

@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
// No-op action methods for test sources
const noActions = {
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((update: Partial<Context>) => void) | null = null
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
},
async fetchContext() {
return { [LocationKey]: currentLocation }
return [[LocationKey, currentLocation]]
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.({ [LocationKey]: location })
callback?.([[LocationKey, location]])
},
}
}
@@ -84,15 +84,15 @@ function createWeatherSource(
...noActions,
async fetchContext(context) {
const location = contextValue(context, LocationKey)
const location = context.get(LocationKey)
if (!location) return null
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
return [[WeatherKey, weather]]
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
const weather = context.get(WeatherKey)
if (!weather) return []
return [
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
const weather = context.get(WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
}
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
let context: Context = { time: new Date() }
const context = new Context()
// Run fetchContext in topological order
for (const source of graph.sorted) {
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
const entries = await source.fetchContext(context)
if (entries) {
context.set(entries)
}
}
@@ -265,7 +265,7 @@ describe("FeedSource", () => {
test("source without context returns null from fetchContext", async () => {
const source = createAlertSource()
const result = await source.fetchContext({ time: new Date() })
const result = await source.fetchContext(new Context())
expect(result).toBeNull()
})
})
@@ -369,7 +369,7 @@ describe("FeedSource", () => {
...noActions,
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
},
}
@@ -379,9 +379,9 @@ describe("FeedSource", () => {
...noActions,
async fetchContext(ctx) {
order.push("weather")
const loc = contextValue(ctx, LocationKey)
const loc = ctx.get(LocationKey)
expect(loc).toBeDefined()
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
},
}
@@ -400,11 +400,11 @@ describe("FeedSource", () => {
const graph = buildGraph([location, weather])
const { context } = await refreshGraph(graph)
expect(contextValue(context, LocationKey)).toEqual({
expect(context.get(LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
expect(contextValue(context, WeatherKey)).toEqual({
expect(context.get(WeatherKey)).toEqual({
temperature: 20,
condition: "sunny",
})
@@ -447,12 +447,10 @@ describe("FeedSource", () => {
})
test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
// Simulate no location available
return null
},
}
@@ -462,7 +460,7 @@ describe("FeedSource", () => {
const graph = buildGraph([location, weather])
const { context, items } = await refreshGraph(graph)
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(context.get(WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
})
@@ -476,7 +474,7 @@ describe("FeedSource", () => {
() => {
updateCount++
},
() => ({ time: new Date() }),
() => new Context(),
)
location.simulateUpdate({ lat: 1, lng: 1 })

View File

@@ -1,5 +1,5 @@
import type { ActionDefinition } from "./action"
import type { Context } from "./context"
import type { Context, ContextEntry } from "./context"
import type { FeedItem } from "./feed"
/**
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
* Maps to: source/contextUpdated (notification, source → host)
*/
onContextUpdate?(
callback: (update: Partial<Context>) => void,
callback: (entries: readonly ContextEntry[]) => void,
getContext: () => Context,
): () => void
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
* Return null if this source cannot provide context.
* Maps to: source/fetchContext
*/
fetchContext(context: Context): Promise<Partial<Context> | null>
fetchContext(context: Context): Promise<readonly ContextEntry[] | null>
/**
* Subscribe to reactive feed item updates.

View File

@@ -1,6 +1,6 @@
// Context
export type { Context, ContextKey } from "./context"
export { contextKey, contextValue } from "./context"
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context"
export { Context, contextKey, serializeKey } from "./context"
// Actions
export type { ActionDefinition } from "./action"

View File

@@ -1,5 +1,6 @@
import type { Context } from "@aris/core"
import type { ContextKey } from "@aris/core"
import { Context, contextKey } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
@@ -15,14 +16,25 @@ const mockCredentials = {
serviceId: "mock",
}
interface LocationData {
lat: number
lng: number
accuracy: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
fetch: async () => response,
})
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
time: new Date("2026-01-17T00:00:00Z"),
location: location ? { ...location, accuracy: 10 } : undefined,
})
function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
if (location) {
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
}
return ctx
}
describe("WeatherKitDataSource", () => {
test("returns empty array when location is missing", async () => {

View File

@@ -1,6 +1,6 @@
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core"
import { TimeRelevance } from "@aris/core"
import { TimeRelevance, contextKey } from "@aris/core"
import {
WeatherFeedItemType,
@@ -40,6 +40,13 @@ export interface WeatherKitQueryConfig {
units?: Units
}
interface LocationData {
lat: number
lng: number
}
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
private readonly DEFAULT_HOURLY_LIMIT = 12
private readonly DEFAULT_DAILY_LIMIT = 7
@@ -59,7 +66,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
}
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
if (!context.location) {
const location = context.get(LocationKey)
if (!location) {
return []
}
@@ -67,8 +75,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
const timestamp = context.time
const response = await this.client.fetch({
lat: context.location.lat,
lng: context.location.lng,
lat: location.lat,
lng: location.lng,
})
const items: WeatherFeedItem[] = []

View File

@@ -5,6 +5,8 @@
* bun run test-live.ts
*/
import { Context } from "@aris/core"
import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:")
@@ -27,7 +29,7 @@ const source = new CalDavSource({
lookAheadDays,
})
const context = { time: new Date() }
const context = new Context()
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)

View File

@@ -1,6 +1,6 @@
import type { Context } from "@aris/core"
import type { ContextEntry } from "@aris/core"
import { TimeRelevance, contextValue } from "@aris/core"
import { Context, TimeRelevance } from "@aris/core"
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
@@ -13,14 +13,21 @@ import type {
} from "./types.ts"
import { CalDavSource, computeSignals } from "./caldav-source.ts"
import { CalDavCalendarKey } from "./calendar-context.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
}
function createContext(time: Date): Context {
return { time }
return new Context(time)
}
/** Extract the CalendarContext value from fetchContext entries. */
function extractCalendar(entries: readonly ContextEntry[] | null): CalendarContext | undefined {
if (!entries) return undefined
const entry = entries.find(([key]) => key === CalDavCalendarKey)
return entry?.[1] as CalendarContext | undefined
}
class MockDAVClient implements CalDavDAVClient {
@@ -302,8 +309,8 @@ describe("CalDavSource.fetchContext", () => {
test("returns empty context when no calendars exist", async () => {
const client = new MockDAVClient([], {})
const source = createSource(client)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar).toBeDefined()
expect(calendar!.inProgress).toEqual([])
@@ -320,8 +327,8 @@ describe("CalDavSource.fetchContext", () => {
const source = createSource(client)
// 14:30 is during the 14:00-15:00 event
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.inProgress).toHaveLength(1)
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
@@ -335,8 +342,8 @@ describe("CalDavSource.fetchContext", () => {
const source = createSource(client)
// 12:00 is before the 14:00 event
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).not.toBeNull()
@@ -350,8 +357,8 @@ describe("CalDavSource.fetchContext", () => {
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).toBeNull()
@@ -369,8 +376,8 @@ describe("CalDavSource.fetchContext", () => {
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = extractCalendar(entries)
expect(calendar!.todayEventCount).toBe(2)
expect(calendar!.hasTodayEvents).toBe(true)

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { TimeRelevance, UnknownActionError } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
@@ -93,17 +93,20 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
const events = await this.fetchEvents(context)
if (events.length === 0) {
return {
[CalDavCalendarKey]: {
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
}
return [
[
CalDavCalendarKey,
{
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
],
]
}
const now = context.time
@@ -121,7 +124,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
todayEventCount: events.length,
}
return { [CalDavCalendarKey]: calendarContext }
return [[CalDavCalendarKey, calendarContext]]
}
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {

View File

@@ -21,4 +21,4 @@ export interface CalendarContext {
todayEventCount: number
}
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar")

View File

@@ -10,4 +10,4 @@ export interface NextEvent {
location: string | null
}
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")
export const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")

View File

@@ -1,10 +1,10 @@
import { TimeRelevance, contextValue, type Context } from "@aris/core"
import { Context, TimeRelevance } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
import fixture from "../fixtures/events.json"
import { NextEventKey } from "./calendar-context"
import { NextEventKey, type NextEvent } from "./calendar-context"
import { CalendarFeedItemType } from "./feed-items"
import { GoogleCalendarSource } from "./google-calendar-source"
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
}
function createContext(time?: Date): Context {
return { time: time ?? NOW }
return new Context(time ?? NOW)
}
describe("GoogleCalendarSource", () => {
@@ -229,15 +229,16 @@ describe("GoogleCalendarSource", () => {
test("returns next upcoming timed event (not ongoing)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const result = await source.fetchContext(createContext())
const entries = await source.fetchContext(createContext())
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
expect(entries).not.toBeNull()
expect(entries).toHaveLength(1)
const [key, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
expect(key).toEqual(NextEventKey)
// evt-soon starts at 10:10, which is the nearest future timed event
expect(nextEvent!.title).toBe("1:1 with Manager")
expect(nextEvent!.minutesUntilStart).toBe(10)
expect(nextEvent!.location).toBeNull()
expect(nextEvent.title).toBe("1:1 with Manager")
expect(nextEvent.minutesUntilStart).toBe(10)
expect(nextEvent.location).toBeNull()
})
test("includes location when available", async () => {
@@ -255,12 +256,11 @@ describe("GoogleCalendarSource", () => {
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const result = await source.fetchContext(createContext())
const entries = await source.fetchContext(createContext())
expect(result).not.toBeNull()
const nextEvent = contextValue(result! as Context, NextEventKey)
expect(nextEvent).toBeDefined()
expect(nextEvent!.location).toBe("123 Main St")
expect(entries).not.toBeNull()
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
expect(nextEvent.location).toBe("123 Main St")
})
test("skips ongoing events for next-event context", async () => {

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { TimeRelevance, UnknownActionError } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import type {
ApiCalendarEvent,
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
* .register(calendarSource)
*
* // Access next-event context in downstream sources
* const next = contextValue(context, NextEventKey)
* const next = context.get(NextEventKey)
* if (next && next.minutesUntilStart < 15) {
* // remind user
* }
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
const events = await this.fetchAllEvents(context.time)
const now = context.time.getTime()
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
location: nextTimedEvent.location,
}
return { [NextEventKey]: nextEvent }
return [[NextEventKey, nextEvent]]
}
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {

View File

@@ -1,6 +1,8 @@
import { describe, expect, mock, test } from "bun:test"
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
import type { Location } from "./types.ts"
import { LocationKey, LocationSource } from "./location-source.ts"
function createLocation(overrides: Partial<Location> = {}): Location {
return {
@@ -39,8 +41,8 @@ describe("LocationSource", () => {
const location = createLocation()
source.pushLocation(location)
const context = await source.fetchContext()
expect(context).toEqual({ [LocationKey]: location })
const entries = await source.fetchContext()
expect(entries).toEqual([[LocationKey, location]])
})
})
@@ -65,7 +67,7 @@ describe("LocationSource", () => {
source.pushLocation(location)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
expect(listener).toHaveBeenCalledWith([[LocationKey, location]])
})
})

View File

@@ -1,11 +1,11 @@
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
import { UnknownActionError, contextKey, type ContextKey } from "@aris/core"
import { Context, UnknownActionError, contextKey, type ContextKey } from "@aris/core"
import { type } from "arktype"
import { Location, type LocationSourceOptions } from "./types.ts"
export const LocationKey: ContextKey<Location> = contextKey("location")
export const LocationKey: ContextKey<Location> = contextKey("aris.location", "location")
/**
* A FeedSource that provides location context.
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
private readonly historySize: number
private locations: Location[] = []
private listeners = new Set<(update: Partial<Context>) => void>()
private listeners = new Set<(entries: readonly ContextEntry[]) => void>()
constructor(options: LocationSourceOptions = {}) {
this.historySize = options.historySize ?? 1
@@ -59,8 +59,9 @@ export class LocationSource implements FeedSource {
if (this.locations.length > this.historySize) {
this.locations.shift()
}
const entries: readonly ContextEntry[] = [[LocationKey, location]]
this.listeners.forEach((listener) => {
listener({ [LocationKey]: location })
listener(entries)
})
}
@@ -78,16 +79,16 @@ export class LocationSource implements FeedSource {
return this.locations
}
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void {
this.listeners.add(callback)
return () => {
this.listeners.delete(callback)
}
}
async fetchContext(): Promise<Partial<Context> | null> {
async fetchContext(): Promise<readonly ContextEntry[] | null> {
if (this.lastLocation) {
return { [LocationKey]: this.lastLocation }
return [[LocationKey, this.lastLocation]]
}
return null
}

View File

@@ -1,5 +1,4 @@
import type { Context } from "@aris/core"
import { Context } from "@aris/core"
import { LocationKey, type Location } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
@@ -81,9 +80,9 @@ class FixtureTflApi implements ITflApi {
}
function createContext(location?: Location): Context {
const ctx: Context = { time: new Date("2026-01-15T12:00:00Z") }
const ctx = new Context(new Date("2026-01-15T12:00:00Z"))
if (location) {
ctx[LocationKey] = location
ctx.set([[LocationKey, location]])
}
return ctx
}

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { type } from "arktype"
@@ -112,7 +112,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
}
}
async fetchContext(): Promise<null> {
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
}
@@ -129,7 +129,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
this.client.fetchStations(),
])
const location = contextValue(context, LocationKey)
const location = context.get(LocationKey)
const items: TflAlertFeedItem[] = statuses.map((status) => {
const closestStationDistance = location

View File

@@ -24,4 +24,4 @@ export interface Weather {
daylight: boolean
}
export const WeatherKey: ContextKey<Weather> = contextKey("weather")
export const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "weather")

View File

@@ -1,4 +1,6 @@
import { contextValue, type Context } from "@aris/core"
import type { FeedSource } from "@aris/core"
import { Context } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { describe, expect, test } from "bun:test"
@@ -6,7 +8,7 @@ import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType } from "./feed-items"
import { WeatherKey } from "./weather-context"
import { WeatherKey, type Weather } from "./weather-context"
import { WeatherSource, Units } from "./weather-source"
const mockCredentials = {
@@ -23,9 +25,9 @@ function createMockClient(response: WeatherKitResponse): WeatherKitClient {
}
function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
if (location) {
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
ctx.set([[LocationKey, { ...location, accuracy: 10, timestamp: new Date() }]])
}
return ctx
}
@@ -63,18 +65,19 @@ describe("WeatherSource", () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const result = await source.fetchContext(context)
expect(result).not.toBeNull()
const weather = contextValue(result! as Context, WeatherKey)
const entries = await source.fetchContext(context)
expect(entries).not.toBeNull()
expect(entries).toHaveLength(1)
expect(weather).toBeDefined()
expect(typeof weather!.temperature).toBe("number")
expect(typeof weather!.temperatureApparent).toBe("number")
expect(typeof weather!.condition).toBe("string")
expect(typeof weather!.humidity).toBe("number")
expect(typeof weather!.uvIndex).toBe("number")
expect(typeof weather!.windSpeed).toBe("number")
expect(typeof weather!.daylight).toBe("boolean")
const [key, weather] = entries![0]! as [typeof WeatherKey, Weather]
expect(key).toEqual(WeatherKey)
expect(typeof weather.temperature).toBe("number")
expect(typeof weather.temperatureApparent).toBe("number")
expect(typeof weather.condition).toBe("string")
expect(typeof weather.humidity).toBe("number")
expect(typeof weather.uvIndex).toBe("number")
expect(typeof weather.windSpeed).toBe("number")
expect(typeof weather.daylight).toBe("boolean")
})
test("converts temperature to imperial", async () => {
@@ -84,12 +87,12 @@ describe("WeatherSource", () => {
})
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const result = await source.fetchContext(context)
expect(result).not.toBeNull()
const weather = contextValue(result! as Context, WeatherKey)
const entries = await source.fetchContext(context)
expect(entries).not.toBeNull()
const [, weather] = entries![0]! as [typeof WeatherKey, Weather]
// Fixture has temperature around 10°C, imperial should be around 50°F
expect(weather!.temperature).toBeGreaterThan(40)
expect(weather.temperature).toBeGreaterThan(40)
})
})
@@ -177,12 +180,12 @@ describe("WeatherSource", () => {
describe("no reactive methods", () => {
test("does not implement onContextUpdate", () => {
const source = new WeatherSource({ credentials: mockCredentials })
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
expect(source.onContextUpdate).toBeUndefined()
})
test("does not implement onItemsUpdate", () => {
const source = new WeatherSource({ credentials: mockCredentials })
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
expect(source.onItemsUpdate).toBeUndefined()
})
})

View File

@@ -1,6 +1,6 @@
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
@@ -86,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
* })
*
* // Access weather context in downstream sources
* const weather = contextValue(context, WeatherKey)
* const weather = context.get(WeatherKey)
* if (weather?.condition === "Rain") {
* // suggest umbrella
* }
@@ -119,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const location = contextValue(context, LocationKey)
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
const location = context.get(LocationKey)
if (!location) {
return null
}
@@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
daylight: response.currentWeather.daylight,
}
return { [WeatherKey]: weather }
return [[WeatherKey, weather]]
}
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
const location = contextValue(context, LocationKey)
const location = context.get(LocationKey)
if (!location) {
return []
}