From 96e22e227c03014cf57cebcd31028c881edaa34c Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 1 Mar 2026 22:52:41 +0000 Subject: [PATCH] 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 --- apps/aris-backend/src/feed/http.test.ts | 4 +- .../src/session/user-session.test.ts | 4 +- packages/aris-core/src/context-bridge.ts | 14 +- packages/aris-core/src/context.test.ts | 184 ++++++++++++++++++ packages/aris-core/src/context.ts | 155 +++++++++++---- packages/aris-core/src/data-source.ts | 2 +- packages/aris-core/src/feed-controller.ts | 14 +- packages/aris-core/src/feed-engine.test.ts | 42 ++-- packages/aris-core/src/feed-engine.ts | 29 +-- .../aris-core/src/feed-post-processor.test.ts | 16 +- packages/aris-core/src/feed-source.test.ts | 44 ++--- packages/aris-core/src/feed-source.ts | 6 +- packages/aris-core/src/index.ts | 4 +- .../src/data-source.test.ts | 22 ++- .../src/data-source.ts | 18 +- .../aris-source-caldav/scripts/test-live.ts | 4 +- .../src/caldav-source.test.ts | 35 ++-- .../aris-source-caldav/src/caldav-source.ts | 27 +-- .../src/calendar-context.ts | 2 +- .../src/calendar-context.ts | 2 +- .../src/google-calendar-source.test.ts | 30 +-- .../src/google-calendar-source.ts | 10 +- .../src/location-source.test.ts | 10 +- .../src/location-source.ts | 17 +- .../aris-source-tfl/src/tfl-source.test.ts | 7 +- packages/aris-source-tfl/src/tfl-source.ts | 8 +- .../src/weather-context.ts | 2 +- .../src/weather-source.test.ts | 45 +++-- .../src/weather-source.ts | 14 +- 29 files changed, 544 insertions(+), 227 deletions(-) create mode 100644 packages/aris-core/src/context.test.ts 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 [] }