diff --git a/apps/aris-backend/src/feed/http.test.ts b/apps/aris-backend/src/feed/http.test.ts index 48fbca2..a87c522 100644 --- a/apps/aris-backend/src/feed/http.test.ts +++ b/apps/aris-backend/src/feed/http.test.ts @@ -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 { return undefined }, - async fetchContext(): Promise | null> { + async fetchContext(): Promise { return null }, async fetchItems() { diff --git a/apps/aris-backend/src/session/user-session.test.ts b/apps/aris-backend/src/session/user-session.test.ts index 33c92da..794221d 100644 --- a/apps/aris-backend/src/session/user-session.test.ts +++ b/apps/aris-backend/src/session/user-session.test.ts @@ -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 { return undefined }, - async fetchContext(): Promise | null> { + async fetchContext(): Promise { return null }, async fetchItems() { diff --git a/packages/aris-core/src/context-bridge.ts b/packages/aris-core/src/context-bridge.ts index 2599e6c..d3fb286 100644 --- a/packages/aris-core/src/context-bridge.ts +++ b/packages/aris-core/src/context-bridge.ts @@ -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): 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 { - const updates: Partial = {} + 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 } } diff --git a/packages/aris-core/src/context.test.ts b/packages/aris-core/src/context.test.ts new file mode 100644 index 0000000..81ea212 --- /dev/null +++ b/packages/aris-core/src/context.test.ts @@ -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 = contextKey("aris.weather", "current") +const NextEventKey: ContextKey = 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("aris.google-calendar", "nextEvent", { + account: "work", + }) + const personalKey = contextKey("aris.google-calendar", "nextEvent", { + account: "personal", + }) + + const ctx = new Context() + ctx.set([ + [workKey, { title: "Sprint Planning" }], + [personalKey, { title: "Dentist" }], + ]) + + const prefix = contextKey("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("aris.google-calendar", "nextEvent") + const instanceKey = contextKey("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("aris.calendar", "next") + const keyB = contextKey("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("source", "ctx", { b: 2, a: 1 }) + const key2 = contextKey("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("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) + }) + }) +}) diff --git a/packages/aris-core/src/context.ts b/packages/aris-core/src/context.ts index 8d1c8fc..5d1ba22 100644 --- a/packages/aris-core/src/context.ts +++ b/packages/aris-core/src/context.ts @@ -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 = 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 = string & { __contextValue?: T } -/** - * Creates a typed context key. - * - * @example - * ```ts - * interface Location { lat: number; lng: number; accuracy: number } - * const LocationKey: ContextKey = contextKey("location") - * ``` - */ -export function contextKey(key: string): ContextKey { - return key as ContextKey +// -- Key types -- + +/** A single segment of a context key: string, number, or a record of primitives. */ +export type ContextKeyPart = string | number | Record + +/** A context key is a readonly tuple of parts, branded with the value type. */ +export type ContextKey = readonly ContextKeyPart[] & { __contextValue?: T } + +/** Creates a typed context key. */ +export function contextKey(...parts: ContextKeyPart[]): ContextKey { + return parts as ContextKey } -/** - * Type-safe accessor for context values. - * - * @example - * ```ts - * const location = contextValue(context, LocationKey) - * if (location) { - * console.log(location.lat, location.lng) - * } - * ``` - */ -export function contextValue(context: Context, key: ContextKey): 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 = {} + 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)[key], + (b as Record)[key], + ), + ) + } + return false +} + +// -- Context store -- + +/** A single context entry: a key-value pair. */ +export type ContextEntry = readonly [ContextKey, 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 + + 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(key: ContextKey): 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(prefix: ContextKey): 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 + } } diff --git a/packages/aris-core/src/data-source.ts b/packages/aris-core/src/data-source.ts index 71ee4d1..2e0561e 100644 --- a/packages/aris-core/src/data-source.ts +++ b/packages/aris-core/src/data-source.ts @@ -12,7 +12,7 @@ import type { FeedItem } from "./feed" * readonly type = "weather" * * async query(context: Context): Promise { - * const location = contextValue(context, LocationKey) + * const location = context.get(LocationKey) * if (!location) return [] * const data = await fetchWeather(location) * return [{ diff --git a/packages/aris-core/src/feed-controller.ts b/packages/aris-core/src/feed-controller.ts index 25aaf08..984423b 100644 --- a/packages/aris-core/src/feed-controller.ts +++ b/packages/aris-core/src/feed-controller.ts @@ -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 { 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 { } } - /** Merges update into context and schedules a debounced reconcile. */ - pushContextUpdate(update: Partial): 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() } diff --git a/packages/aris-core/src/feed-engine.test.ts b/packages/aris-core/src/feed-engine.test.ts index e506f5b..0df1035 100644 --- a/packages/aris-core/src/feed-engine.test.ts +++ b/packages/aris-core/src/feed-engine.test.ts @@ -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) => 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 { }, 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 [ { diff --git a/packages/aris-core/src/feed-engine.ts b/packages/aris-core/src/feed-engine.ts index 97c9d62..f1baf6f 100644 --- a/packages/aris-core/src/feed-engine.ts +++ b/packages/aris-core/src/feed-engine.ts @@ -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 { private sources = new Map() private graph: SourceGraph | null = null - private context: Context = { time: new Date() } + private context: Context = new Context() private subscribers = new Set>() private cleanups: Array<() => void> = [] private started = false @@ -138,14 +140,14 @@ export class FeedEngine { 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 { 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 { return this.graph } - private handleContextUpdate(sourceId: string, update: Partial): 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 { 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 diff --git a/packages/aris-core/src/feed-post-processor.test.ts b/packages/aris-core/src/feed-post-processor.test.ts index d350907..03abd41 100644 --- a/packages/aris-core/src/feed-post-processor.test.ts +++ b/packages/aris-core/src/feed-post-processor.test.ts @@ -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) => 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) diff --git a/packages/aris-core/src/feed-source.test.ts b/packages/aris-core/src/feed-source.test.ts index 0b4785a..becf57d 100644 --- a/packages/aris-core/src/feed-source.test.ts +++ b/packages/aris-core/src/feed-source.test.ts @@ -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) => 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 { }, 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 }) diff --git a/packages/aris-core/src/feed-source.ts b/packages/aris-core/src/feed-source.ts index df577ff..cb95c31 100644 --- a/packages/aris-core/src/feed-source.ts +++ b/packages/aris-core/src/feed-source.ts @@ -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 { * Maps to: source/contextUpdated (notification, source → host) */ onContextUpdate?( - callback: (update: Partial) => void, + callback: (entries: readonly ContextEntry[]) => void, getContext: () => Context, ): () => void @@ -67,7 +67,7 @@ export interface FeedSource { * Return null if this source cannot provide context. * Maps to: source/fetchContext */ - fetchContext(context: Context): Promise | null> + fetchContext(context: Context): Promise /** * Subscribe to reactive feed item updates. diff --git a/packages/aris-core/src/index.ts b/packages/aris-core/src/index.ts index ecd3348..f9e4158 100644 --- a/packages/aris-core/src/index.ts +++ b/packages/aris-core/src/index.ts @@ -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" diff --git a/packages/aris-data-source-weatherkit/src/data-source.test.ts b/packages/aris-data-source-weatherkit/src/data-source.test.ts index e791f11..58b66f1 100644 --- a/packages/aris-data-source-weatherkit/src/data-source.test.ts +++ b/packages/aris-data-source-weatherkit/src/data-source.test.ts @@ -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 = 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 () => { diff --git a/packages/aris-data-source-weatherkit/src/data-source.ts b/packages/aris-data-source-weatherkit/src/data-source.ts index dce1c91..b2f8de9 100644 --- a/packages/aris-data-source-weatherkit/src/data-source.ts +++ b/packages/aris-data-source-weatherkit/src/data-source.ts @@ -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 = contextKey("aris.location", "location") + export class WeatherKitDataSource implements DataSource { private readonly DEFAULT_HOURLY_LIMIT = 12 private readonly DEFAULT_DAILY_LIMIT = 7 @@ -59,7 +66,8 @@ export class WeatherKitDataSource implements DataSource { - if (!context.location) { + const location = context.get(LocationKey) + if (!location) { return [] } @@ -67,8 +75,8 @@ export class WeatherKitDataSource implements DataSource 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) diff --git a/packages/aris-source-caldav/src/caldav-source.ts b/packages/aris-source-caldav/src/caldav-source.ts index d0f3d37..9020680 100644 --- a/packages/aris-source-caldav/src/caldav-source.ts +++ b/packages/aris-source-caldav/src/caldav-source.ts @@ -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 { throw new UnknownActionError(actionId) } - async fetchContext(context: Context): Promise | null> { + async fetchContext(context: Context): Promise { 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 { todayEventCount: events.length, } - return { [CalDavCalendarKey]: calendarContext } + return [[CalDavCalendarKey, calendarContext]] } async fetchItems(context: Context): Promise { diff --git a/packages/aris-source-caldav/src/calendar-context.ts b/packages/aris-source-caldav/src/calendar-context.ts index b1245db..95e149e 100644 --- a/packages/aris-source-caldav/src/calendar-context.ts +++ b/packages/aris-source-caldav/src/calendar-context.ts @@ -21,4 +21,4 @@ export interface CalendarContext { todayEventCount: number } -export const CalDavCalendarKey: ContextKey = contextKey("caldavCalendar") +export const CalDavCalendarKey: ContextKey = contextKey("aris.caldav", "calendar") diff --git a/packages/aris-source-google-calendar/src/calendar-context.ts b/packages/aris-source-google-calendar/src/calendar-context.ts index cd1a942..c8133da 100644 --- a/packages/aris-source-google-calendar/src/calendar-context.ts +++ b/packages/aris-source-google-calendar/src/calendar-context.ts @@ -10,4 +10,4 @@ export interface NextEvent { location: string | null } -export const NextEventKey: ContextKey = contextKey("nextEvent") +export const NextEventKey: ContextKey = contextKey("aris.google-calendar", "nextEvent") diff --git a/packages/aris-source-google-calendar/src/google-calendar-source.test.ts b/packages/aris-source-google-calendar/src/google-calendar-source.test.ts index 904e795..2ddfe33 100644 --- a/packages/aris-source-google-calendar/src/google-calendar-source.test.ts +++ b/packages/aris-source-google-calendar/src/google-calendar-source.test.ts @@ -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 () => { diff --git a/packages/aris-source-google-calendar/src/google-calendar-source.ts b/packages/aris-source-google-calendar/src/google-calendar-source.ts index 678818e..2f43c02 100644 --- a/packages/aris-source-google-calendar/src/google-calendar-source.ts +++ b/packages/aris-source-google-calendar/src/google-calendar-source.ts @@ -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 { throw new UnknownActionError(actionId) } - async fetchContext(context: Context): Promise | null> { + async fetchContext(context: Context): Promise { const events = await this.fetchAllEvents(context.time) const now = context.time.getTime() @@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource { location: nextTimedEvent.location, } - return { [NextEventKey]: nextEvent } + return [[NextEventKey, nextEvent]] } async fetchItems(context: Context): Promise { diff --git a/packages/aris-source-location/src/location-source.test.ts b/packages/aris-source-location/src/location-source.test.ts index af76707..8e287a2 100644 --- a/packages/aris-source-location/src/location-source.test.ts +++ b/packages/aris-source-location/src/location-source.test.ts @@ -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 { 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]]) }) }) diff --git a/packages/aris-source-location/src/location-source.ts b/packages/aris-source-location/src/location-source.ts index 20e6ec2..78f9cfc 100644 --- a/packages/aris-source-location/src/location-source.ts +++ b/packages/aris-source-location/src/location-source.ts @@ -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 = contextKey("location") +export const LocationKey: ContextKey = 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) => 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) => void): () => void { + onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void { this.listeners.add(callback) return () => { this.listeners.delete(callback) } } - async fetchContext(): Promise | null> { + async fetchContext(): Promise { if (this.lastLocation) { - return { [LocationKey]: this.lastLocation } + return [[LocationKey, this.lastLocation]] } return null } diff --git a/packages/aris-source-tfl/src/tfl-source.test.ts b/packages/aris-source-tfl/src/tfl-source.test.ts index 9653f85..cbd368a 100644 --- a/packages/aris-source-tfl/src/tfl-source.test.ts +++ b/packages/aris-source-tfl/src/tfl-source.test.ts @@ -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 } diff --git a/packages/aris-source-tfl/src/tfl-source.ts b/packages/aris-source-tfl/src/tfl-source.ts index 3ac2f7d..aa6f1bc 100644 --- a/packages/aris-source-tfl/src/tfl-source.ts +++ b/packages/aris-source-tfl/src/tfl-source.ts @@ -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 { } } - async fetchContext(): Promise { + async fetchContext(): Promise { return null } @@ -129,7 +129,7 @@ export class TflSource implements FeedSource { this.client.fetchStations(), ]) - const location = contextValue(context, LocationKey) + const location = context.get(LocationKey) const items: TflAlertFeedItem[] = statuses.map((status) => { const closestStationDistance = location diff --git a/packages/aris-source-weatherkit/src/weather-context.ts b/packages/aris-source-weatherkit/src/weather-context.ts index 11a027e..eadf53e 100644 --- a/packages/aris-source-weatherkit/src/weather-context.ts +++ b/packages/aris-source-weatherkit/src/weather-context.ts @@ -24,4 +24,4 @@ export interface Weather { daylight: boolean } -export const WeatherKey: ContextKey = contextKey("weather") +export const WeatherKey: ContextKey = contextKey("aris.weather", "weather") diff --git a/packages/aris-source-weatherkit/src/weather-source.test.ts b/packages/aris-source-weatherkit/src/weather-source.test.ts index 18749c2..88b4994 100644 --- a/packages/aris-source-weatherkit/src/weather-source.test.ts +++ b/packages/aris-source-weatherkit/src/weather-source.test.ts @@ -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() }) }) diff --git a/packages/aris-source-weatherkit/src/weather-source.ts b/packages/aris-source-weatherkit/src/weather-source.ts index 9247fcd..92df059 100644 --- a/packages/aris-source-weatherkit/src/weather-source.ts +++ b/packages/aris-source-weatherkit/src/weather-source.ts @@ -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([ * }) * * // 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 { throw new UnknownActionError(actionId) } - async fetchContext(context: Context): Promise | null> { - const location = contextValue(context, LocationKey) + async fetchContext(context: Context): Promise { + const location = context.get(LocationKey) if (!location) { return null } @@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource { daylight: response.currentWeather.daylight, } - return { [WeatherKey]: weather } + return [[WeatherKey, weather]] } async fetchItems(context: Context): Promise { - const location = contextValue(context, LocationKey) + const location = context.get(LocationKey) if (!location) { return [] }