mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 00:51:20 +00:00
feat: replace flat context with tuple-keyed store
Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.
Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().
Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<Partial<Context> | null> {
|
||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
||||
|
||||
import { LocationSource } from "@aris/source-location"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<Partial<Context> | null> {
|
||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Context } from "./context"
|
||||
import type { ContextEntry } from "./context"
|
||||
import type { ContextProvider } from "./context-provider"
|
||||
|
||||
import { contextKey } from "./context"
|
||||
|
||||
interface ContextUpdatable {
|
||||
pushContextUpdate(update: Partial<Context>): void
|
||||
pushContextUpdate(entries: readonly ContextEntry[]): void
|
||||
}
|
||||
|
||||
export interface ProviderError {
|
||||
@@ -54,7 +56,7 @@ export class ContextBridge {
|
||||
this.providers.set(provider.key, provider as ContextProvider)
|
||||
|
||||
const cleanup = provider.onUpdate((value) => {
|
||||
this.controller.pushContextUpdate({ [provider.key]: value })
|
||||
this.controller.pushContextUpdate([[contextKey(provider.key), value]])
|
||||
})
|
||||
this.cleanups.push(cleanup)
|
||||
|
||||
@@ -67,7 +69,7 @@ export class ContextBridge {
|
||||
* Returns errors from providers that failed to fetch.
|
||||
*/
|
||||
async refresh(): Promise<RefreshResult> {
|
||||
const updates: Partial<Context> = {}
|
||||
const collected: ContextEntry[] = []
|
||||
const errors: ProviderError[] = []
|
||||
|
||||
const entries = Array.from(this.providers.entries())
|
||||
@@ -78,7 +80,7 @@ export class ContextBridge {
|
||||
entries.forEach(([key], i) => {
|
||||
const result = results[i]
|
||||
if (result?.status === "fulfilled") {
|
||||
updates[key] = result.value
|
||||
collected.push([contextKey(key), result.value])
|
||||
} else if (result?.status === "rejected") {
|
||||
errors.push({
|
||||
key,
|
||||
@@ -87,7 +89,7 @@ export class ContextBridge {
|
||||
}
|
||||
})
|
||||
|
||||
this.controller.pushContextUpdate(updates)
|
||||
this.controller.pushContextUpdate(collected)
|
||||
|
||||
return { errors }
|
||||
}
|
||||
|
||||
184
packages/aris-core/src/context.test.ts
Normal file
184
packages/aris-core/src/context.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ContextKey } from "./context"
|
||||
|
||||
import { Context, contextKey } from "./context"
|
||||
|
||||
interface Weather {
|
||||
temperature: number
|
||||
}
|
||||
|
||||
interface NextEvent {
|
||||
title: string
|
||||
}
|
||||
|
||||
const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "current")
|
||||
const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
||||
|
||||
describe("Context", () => {
|
||||
describe("get", () => {
|
||||
test("returns undefined for missing key", () => {
|
||||
const ctx = new Context()
|
||||
expect(ctx.get(WeatherKey)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns value for exact key match", () => {
|
||||
const ctx = new Context()
|
||||
const weather: Weather = { temperature: 20 }
|
||||
ctx.set([[WeatherKey, weather]])
|
||||
|
||||
expect(ctx.get(WeatherKey)).toEqual(weather)
|
||||
})
|
||||
|
||||
test("distinguishes keys with different parts", () => {
|
||||
const ctx = new Context()
|
||||
ctx.set([
|
||||
[WeatherKey, { temperature: 20 }],
|
||||
[NextEventKey, { title: "Standup" }],
|
||||
])
|
||||
|
||||
expect(ctx.get(WeatherKey)).toEqual({ temperature: 20 })
|
||||
expect(ctx.get(NextEventKey)).toEqual({ title: "Standup" })
|
||||
})
|
||||
|
||||
test("last write wins for same key", () => {
|
||||
const ctx = new Context()
|
||||
ctx.set([[WeatherKey, { temperature: 20 }]])
|
||||
ctx.set([[WeatherKey, { temperature: 25 }]])
|
||||
|
||||
expect(ctx.get(WeatherKey)).toEqual({ temperature: 25 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("find", () => {
|
||||
test("returns empty array when no keys match", () => {
|
||||
const ctx = new Context()
|
||||
expect(ctx.find(WeatherKey)).toEqual([])
|
||||
})
|
||||
|
||||
test("returns exact match as single result", () => {
|
||||
const ctx = new Context()
|
||||
ctx.set([[NextEventKey, { title: "Standup" }]])
|
||||
|
||||
const results = ctx.find(NextEventKey)
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.value).toEqual({ title: "Standup" })
|
||||
})
|
||||
|
||||
test("prefix match returns multiple instances", () => {
|
||||
const workKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
||||
account: "work",
|
||||
})
|
||||
const personalKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
||||
account: "personal",
|
||||
})
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.set([
|
||||
[workKey, { title: "Sprint Planning" }],
|
||||
[personalKey, { title: "Dentist" }],
|
||||
])
|
||||
|
||||
const prefix = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
||||
const results = ctx.find(prefix)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
const titles = results.map((r) => r.value.title).sort()
|
||||
expect(titles).toEqual(["Dentist", "Sprint Planning"])
|
||||
})
|
||||
|
||||
test("prefix match includes exact match and longer keys", () => {
|
||||
const baseKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent")
|
||||
const instanceKey = contextKey<NextEvent>("aris.google-calendar", "nextEvent", {
|
||||
account: "work",
|
||||
})
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.set([
|
||||
[baseKey, { title: "Base" }],
|
||||
[instanceKey, { title: "Instance" }],
|
||||
])
|
||||
|
||||
const results = ctx.find(baseKey)
|
||||
expect(results).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("does not match keys that share a string prefix but differ at segment boundary", () => {
|
||||
const keyA = contextKey<string>("aris.calendar", "next")
|
||||
const keyB = contextKey<string>("aris.calendar", "nextEvent")
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.set([
|
||||
[keyA, "a"],
|
||||
[keyB, "b"],
|
||||
])
|
||||
|
||||
const results = ctx.find(keyA)
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.value).toBe("a")
|
||||
})
|
||||
|
||||
test("object key parts with different property order match", () => {
|
||||
const key1 = contextKey<string>("source", "ctx", { b: 2, a: 1 })
|
||||
const key2 = contextKey<string>("source", "ctx", { a: 1, b: 2 })
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.set([[key1, "value"]])
|
||||
|
||||
// Exact match via get should work regardless of property order
|
||||
expect(ctx.get(key2)).toBe("value")
|
||||
|
||||
// find with the reordered key as prefix should also match
|
||||
const prefix = contextKey<string>("source", "ctx")
|
||||
const results = ctx.find(prefix)
|
||||
expect(results).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("single-segment prefix matches all keys starting with that segment", () => {
|
||||
const ctx = new Context()
|
||||
ctx.set([
|
||||
[contextKey("aris.weather", "current"), { temperature: 20 }],
|
||||
[contextKey("aris.weather", "forecast"), { high: 25 }],
|
||||
[contextKey("aris.calendar", "nextEvent"), { title: "Meeting" }],
|
||||
])
|
||||
|
||||
const results = ctx.find(contextKey("aris.weather"))
|
||||
expect(results).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("does not match shorter keys", () => {
|
||||
const ctx = new Context()
|
||||
ctx.set([[contextKey("aris.weather"), "short"]])
|
||||
|
||||
const results = ctx.find(contextKey("aris.weather", "current"))
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("numeric key parts match correctly", () => {
|
||||
const ctx = new Context()
|
||||
ctx.set([
|
||||
[contextKey("source", 1, "data"), "one"],
|
||||
[contextKey("source", 2, "data"), "two"],
|
||||
])
|
||||
|
||||
const results = ctx.find(contextKey("source", 1))
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.value).toBe("one")
|
||||
})
|
||||
})
|
||||
|
||||
describe("size", () => {
|
||||
test("returns 0 for empty context", () => {
|
||||
expect(new Context().size).toBe(0)
|
||||
})
|
||||
|
||||
test("reflects number of entries", () => {
|
||||
const ctx = new Context()
|
||||
ctx.set([
|
||||
[WeatherKey, { temperature: 20 }],
|
||||
[NextEventKey, { title: "Standup" }],
|
||||
])
|
||||
expect(ctx.size).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,46 +1,131 @@
|
||||
/**
|
||||
* Branded type for type-safe context keys.
|
||||
* Tuple-keyed context system inspired by React Query's query keys.
|
||||
*
|
||||
* Each package defines its own keys with associated value types:
|
||||
* ```ts
|
||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||
* ```
|
||||
* Context keys are arrays that form a hierarchy. Sources write to specific
|
||||
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
|
||||
* and consumers can query by exact match or prefix match to get all values
|
||||
* of a given type across source instances.
|
||||
*/
|
||||
export type ContextKey<T> = string & { __contextValue?: T }
|
||||
|
||||
/**
|
||||
* Creates a typed context key.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface Location { lat: number; lng: number; accuracy: number }
|
||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||
* ```
|
||||
*/
|
||||
export function contextKey<T>(key: string): ContextKey<T> {
|
||||
return key as ContextKey<T>
|
||||
// -- Key types --
|
||||
|
||||
/** A single segment of a context key: string, number, or a record of primitives. */
|
||||
export type ContextKeyPart = string | number | Record<string, unknown>
|
||||
|
||||
/** A context key is a readonly tuple of parts, branded with the value type. */
|
||||
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
|
||||
|
||||
/** Creates a typed context key. */
|
||||
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
|
||||
return parts as ContextKey<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe accessor for context values.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const location = contextValue(context, LocationKey)
|
||||
* if (location) {
|
||||
* console.log(location.lat, location.lng)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
|
||||
return context[key] as T | undefined
|
||||
}
|
||||
// -- Serialization --
|
||||
|
||||
/**
|
||||
* Arbitrary key-value bag representing the current state.
|
||||
* Always includes `time`. Other keys are added by context providers.
|
||||
* Deterministic serialization of a context key for use as a Map key.
|
||||
* Object parts have their keys sorted for stable comparison.
|
||||
*/
|
||||
export interface Context {
|
||||
export function serializeKey(key: readonly ContextKeyPart[]): string {
|
||||
return JSON.stringify(key, (_key, value) => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
const sorted: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(value).sort()) {
|
||||
sorted[k] = value[k]
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
// -- Key matching --
|
||||
|
||||
/** Returns true if `key` starts with all parts of `prefix`. */
|
||||
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
|
||||
if (key.length < prefix.length) return false
|
||||
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (!partsEqual(key[i]!, prefix[i]!)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
|
||||
function partsEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a !== typeof b) return false
|
||||
if (a && b && typeof a === "object" && typeof b === "object") {
|
||||
const aKeys = Object.keys(a)
|
||||
const bKeys = Object.keys(b)
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
return aKeys.every((key) =>
|
||||
partsEqual(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key],
|
||||
),
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// -- Context store --
|
||||
|
||||
/** A single context entry: a key-value pair. */
|
||||
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
|
||||
|
||||
/**
|
||||
* Mutable context store with tuple keys.
|
||||
*
|
||||
* Supports exact-match lookups and prefix-match queries.
|
||||
* Sources write context in topological order during refresh.
|
||||
*/
|
||||
export class Context {
|
||||
time: Date
|
||||
[key: string]: unknown
|
||||
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
|
||||
|
||||
constructor(time: Date = new Date()) {
|
||||
this.time = time
|
||||
this.store = new Map()
|
||||
}
|
||||
|
||||
/** Merges entries into this context. */
|
||||
set(entries: readonly ContextEntry[]): void {
|
||||
for (const [key, value] of entries) {
|
||||
this.store.set(serializeKey(key), { key, value })
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact-match lookup. Returns the value for the given key, or undefined. */
|
||||
get<T>(key: ContextKey<T>): T | undefined {
|
||||
const entry = this.store.get(serializeKey(key))
|
||||
return entry?.value as T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix-match query. Returns all entries whose key starts with the given prefix.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Get all "nextEvent" values across calendar source instances
|
||||
* const events = context.find(contextKey("nextEvent"))
|
||||
* ```
|
||||
*/
|
||||
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> {
|
||||
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = []
|
||||
|
||||
for (const entry of this.store.values()) {
|
||||
if (keyStartsWith(entry.key, prefix)) {
|
||||
results.push({ key: entry.key, value: entry.value as T })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/** Returns the number of entries (excluding time). */
|
||||
get size(): number {
|
||||
return this.store.size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { FeedItem } from "./feed"
|
||||
* readonly type = "weather"
|
||||
*
|
||||
* async query(context: Context): Promise<WeatherItem[]> {
|
||||
* const location = contextValue(context, LocationKey)
|
||||
* const location = context.get(LocationKey)
|
||||
* if (!location) return []
|
||||
* const data = await fetchWeather(location)
|
||||
* return [{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Context } from "./context"
|
||||
import type { ContextEntry } from "./context"
|
||||
import type { DataSource } from "./data-source"
|
||||
import type { FeedItem } from "./feed"
|
||||
import type { ReconcileResult } from "./reconciler"
|
||||
|
||||
import { Context } from "./context"
|
||||
import { Reconciler } from "./reconciler"
|
||||
|
||||
export interface FeedControllerConfig {
|
||||
@@ -40,7 +41,7 @@ const DEFAULT_DEBOUNCE_MS = 100
|
||||
* })
|
||||
*
|
||||
* // Context update triggers debounced reconcile
|
||||
* controller.pushContextUpdate({ [LocationKey]: location })
|
||||
* controller.pushContextUpdate([[LocationKey, location]])
|
||||
*
|
||||
* // Direct reconcile (no debounce)
|
||||
* const result = await controller.reconcile()
|
||||
@@ -59,7 +60,7 @@ export class FeedController<TItems extends FeedItem = never> {
|
||||
private stopped = false
|
||||
|
||||
constructor(config?: FeedControllerConfig) {
|
||||
this.context = config?.initialContext ?? { time: new Date() }
|
||||
this.context = config?.initialContext ?? new Context()
|
||||
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
||||
this.timeout = config?.timeout
|
||||
}
|
||||
@@ -94,9 +95,10 @@ export class FeedController<TItems extends FeedItem = never> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges update into context and schedules a debounced reconcile. */
|
||||
pushContextUpdate(update: Partial<Context>): void {
|
||||
this.context = { ...this.context, ...update, time: new Date() }
|
||||
/** Merges entries into context and schedules a debounced reconcile. */
|
||||
pushContextUpdate(entries: readonly ContextEntry[]): void {
|
||||
this.context.time = new Date()
|
||||
this.context.set(entries)
|
||||
this.scheduleReconcile()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
|
||||
import { FeedEngine } from "./feed-engine"
|
||||
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
||||
|
||||
// No-op action methods for test sources
|
||||
const noActions = {
|
||||
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
|
||||
}
|
||||
|
||||
function createLocationSource(): SimulatedLocationSource {
|
||||
let callback: ((update: Partial<Context>) => void) | null = null
|
||||
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||
|
||||
return {
|
||||
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
|
||||
},
|
||||
|
||||
async fetchContext() {
|
||||
return { [LocationKey]: currentLocation }
|
||||
return [[LocationKey, currentLocation]]
|
||||
},
|
||||
|
||||
simulateUpdate(location: Location) {
|
||||
currentLocation = location
|
||||
callback?.({ [LocationKey]: location })
|
||||
callback?.([[LocationKey, location]])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -85,15 +85,15 @@ function createWeatherSource(
|
||||
...noActions,
|
||||
|
||||
async fetchContext(context) {
|
||||
const location = contextValue(context, LocationKey)
|
||||
const location = context.get(LocationKey)
|
||||
if (!location) return null
|
||||
|
||||
const weather = await fetchWeather(location)
|
||||
return { [WeatherKey]: weather }
|
||||
return [[WeatherKey, weather]]
|
||||
},
|
||||
|
||||
async fetchItems(context) {
|
||||
const weather = contextValue(context, WeatherKey)
|
||||
const weather = context.get(WeatherKey)
|
||||
if (!weather) return []
|
||||
|
||||
return [
|
||||
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
||||
},
|
||||
|
||||
async fetchItems(context) {
|
||||
const weather = contextValue(context, WeatherKey)
|
||||
const weather = context.get(WeatherKey)
|
||||
if (!weather) return []
|
||||
|
||||
if (weather.condition === "storm") {
|
||||
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
order.push("location")
|
||||
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
||||
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -275,9 +275,9 @@ describe("FeedEngine", () => {
|
||||
...noActions,
|
||||
async fetchContext(ctx) {
|
||||
order.push("weather")
|
||||
const loc = contextValue(ctx, LocationKey)
|
||||
const loc = ctx.get(LocationKey)
|
||||
expect(loc).toBeDefined()
|
||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -298,11 +298,11 @@ describe("FeedEngine", () => {
|
||||
|
||||
const { context } = await engine.refresh()
|
||||
|
||||
expect(contextValue(context, LocationKey)).toEqual({
|
||||
expect(context.get(LocationKey)).toEqual({
|
||||
lat: 51.5,
|
||||
lng: -0.1,
|
||||
})
|
||||
expect(contextValue(context, WeatherKey)).toEqual({
|
||||
expect(context.get(WeatherKey)).toEqual({
|
||||
temperature: 20,
|
||||
condition: "sunny",
|
||||
})
|
||||
@@ -361,7 +361,7 @@ describe("FeedEngine", () => {
|
||||
|
||||
const { context, items } = await engine.refresh()
|
||||
|
||||
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
||||
expect(context.get(WeatherKey)).toBeUndefined()
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -459,7 +459,7 @@ describe("FeedEngine", () => {
|
||||
await engine.refresh()
|
||||
|
||||
const context = engine.currentContext()
|
||||
expect(contextValue(context, LocationKey)).toEqual({
|
||||
expect(context.get(LocationKey)).toEqual({
|
||||
lat: 51.5,
|
||||
lng: -0.1,
|
||||
})
|
||||
@@ -734,7 +734,7 @@ describe("FeedEngine", () => {
|
||||
})
|
||||
|
||||
test("reactive item update refreshes cache", async () => {
|
||||
let itemUpdateCallback: (() => void) | null = null
|
||||
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null
|
||||
|
||||
const source: FeedSource = {
|
||||
id: "reactive-items",
|
||||
@@ -765,7 +765,7 @@ describe("FeedEngine", () => {
|
||||
engine.start()
|
||||
|
||||
// Trigger item update
|
||||
itemUpdateCallback!()
|
||||
itemUpdateCallback!([])
|
||||
|
||||
// Wait for async refresh
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
@@ -885,12 +885,12 @@ describe("FeedEngine", () => {
|
||||
...noActions,
|
||||
async fetchContext(ctx) {
|
||||
fetchCount++
|
||||
const loc = contextValue(ctx, LocationKey)
|
||||
const loc = ctx.get(LocationKey)
|
||||
if (!loc) return null
|
||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
||||
},
|
||||
async fetchItems(ctx) {
|
||||
const weather = contextValue(ctx, WeatherKey)
|
||||
const weather = ctx.get(WeatherKey)
|
||||
if (!weather) return []
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ActionDefinition } from "./action"
|
||||
import type { Context } from "./context"
|
||||
import type { ContextEntry } from "./context"
|
||||
import type { FeedItem } from "./feed"
|
||||
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||
import type { FeedSource } from "./feed-source"
|
||||
|
||||
import { Context } from "./context"
|
||||
|
||||
export interface SourceError {
|
||||
sourceId: string
|
||||
error: Error
|
||||
@@ -65,7 +67,7 @@ interface SourceGraph {
|
||||
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
private sources = new Map<string, FeedSource>()
|
||||
private graph: SourceGraph | null = null
|
||||
private context: Context = { time: new Date() }
|
||||
private context: Context = new Context()
|
||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||
private cleanups: Array<() => void> = []
|
||||
private started = false
|
||||
@@ -138,14 +140,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
const errors: SourceError[] = []
|
||||
|
||||
// Reset context with fresh time
|
||||
let context: Context = { time: new Date() }
|
||||
const context = new Context()
|
||||
|
||||
// Run fetchContext in topological order
|
||||
for (const source of graph.sorted) {
|
||||
try {
|
||||
const update = await source.fetchContext(context)
|
||||
if (update) {
|
||||
context = { ...context, ...update }
|
||||
const entries = await source.fetchContext(context)
|
||||
if (entries) {
|
||||
context.set(entries)
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
@@ -213,8 +215,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
for (const source of graph.sorted) {
|
||||
if (source.onContextUpdate) {
|
||||
const cleanup = source.onContextUpdate(
|
||||
(update) => {
|
||||
this.handleContextUpdate(source.id, update)
|
||||
(entries) => {
|
||||
this.handleContextUpdate(source.id, entries)
|
||||
},
|
||||
() => this.context,
|
||||
)
|
||||
@@ -365,8 +367,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
return this.graph
|
||||
}
|
||||
|
||||
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
|
||||
this.context = { ...this.context, ...update, time: new Date() }
|
||||
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void {
|
||||
this.context.time = new Date()
|
||||
this.context.set(entries)
|
||||
|
||||
// Re-run dependents and notify
|
||||
this.refreshDependents(sourceId)
|
||||
@@ -381,9 +384,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
const source = graph.sources.get(id)
|
||||
if (source) {
|
||||
try {
|
||||
const update = await source.fetchContext(this.context)
|
||||
if (update) {
|
||||
this.context = { ...this.context, ...update }
|
||||
const entries = await source.fetchContext(this.context)
|
||||
if (entries) {
|
||||
this.context.set(entries)
|
||||
}
|
||||
} catch {
|
||||
// Errors during reactive updates are logged but don't stop propagation
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
|
||||
import type {
|
||||
ActionDefinition,
|
||||
ContextEntry,
|
||||
FeedItem,
|
||||
FeedPostProcessor,
|
||||
FeedSource,
|
||||
} from "./index"
|
||||
|
||||
import { FeedEngine } from "./feed-engine"
|
||||
import { UnknownActionError } from "./index"
|
||||
@@ -471,7 +477,7 @@ describe("FeedPostProcessor", () => {
|
||||
test("post-processors run during reactive context updates", async () => {
|
||||
let callCount = 0
|
||||
|
||||
let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
|
||||
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null
|
||||
|
||||
const source: FeedSource = {
|
||||
id: "aris.reactive",
|
||||
@@ -502,7 +508,7 @@ describe("FeedPostProcessor", () => {
|
||||
const countAfterStart = callCount
|
||||
|
||||
// Trigger a reactive context update
|
||||
triggerUpdate!({ foo: "bar" })
|
||||
triggerUpdate!([])
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||
@@ -513,7 +519,7 @@ describe("FeedPostProcessor", () => {
|
||||
test("post-processors run during reactive item updates", async () => {
|
||||
let callCount = 0
|
||||
|
||||
let triggerItemsUpdate: (() => void) | null = null
|
||||
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null
|
||||
|
||||
const source: FeedSource = {
|
||||
id: "aris.reactive",
|
||||
@@ -543,7 +549,7 @@ describe("FeedPostProcessor", () => {
|
||||
const countAfterStart = callCount
|
||||
|
||||
// Trigger a reactive items update
|
||||
triggerItemsUpdate!()
|
||||
triggerItemsUpdate!([weatherItem("w1", 25)])
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"
|
||||
|
||||
import { TimeRelevance, UnknownActionError, contextKey, contextValue } from "./index"
|
||||
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"
|
||||
|
||||
// No-op action methods for test sources
|
||||
const noActions = {
|
||||
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
|
||||
}
|
||||
|
||||
function createLocationSource(): SimulatedLocationSource {
|
||||
let callback: ((update: Partial<Context>) => void) | null = null
|
||||
let callback: ((entries: readonly ContextEntry[]) => void) | null = null
|
||||
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||
|
||||
return {
|
||||
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
|
||||
},
|
||||
|
||||
async fetchContext() {
|
||||
return { [LocationKey]: currentLocation }
|
||||
return [[LocationKey, currentLocation]]
|
||||
},
|
||||
|
||||
simulateUpdate(location: Location) {
|
||||
currentLocation = location
|
||||
callback?.({ [LocationKey]: location })
|
||||
callback?.([[LocationKey, location]])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -84,15 +84,15 @@ function createWeatherSource(
|
||||
...noActions,
|
||||
|
||||
async fetchContext(context) {
|
||||
const location = contextValue(context, LocationKey)
|
||||
const location = context.get(LocationKey)
|
||||
if (!location) return null
|
||||
|
||||
const weather = await fetchWeather(location)
|
||||
return { [WeatherKey]: weather }
|
||||
return [[WeatherKey, weather]]
|
||||
},
|
||||
|
||||
async fetchItems(context) {
|
||||
const weather = contextValue(context, WeatherKey)
|
||||
const weather = context.get(WeatherKey)
|
||||
if (!weather) return []
|
||||
|
||||
return [
|
||||
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
||||
},
|
||||
|
||||
async fetchItems(context) {
|
||||
const weather = contextValue(context, WeatherKey)
|
||||
const weather = context.get(WeatherKey)
|
||||
if (!weather) return []
|
||||
|
||||
if (weather.condition === "storm") {
|
||||
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
|
||||
}
|
||||
|
||||
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
||||
let context: Context = { time: new Date() }
|
||||
const context = new Context()
|
||||
|
||||
// Run fetchContext in topological order
|
||||
for (const source of graph.sorted) {
|
||||
const update = await source.fetchContext(context)
|
||||
if (update) {
|
||||
context = { ...context, ...update }
|
||||
const entries = await source.fetchContext(context)
|
||||
if (entries) {
|
||||
context.set(entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ describe("FeedSource", () => {
|
||||
|
||||
test("source without context returns null from fetchContext", async () => {
|
||||
const source = createAlertSource()
|
||||
const result = await source.fetchContext({ time: new Date() })
|
||||
const result = await source.fetchContext(new Context())
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -369,7 +369,7 @@ describe("FeedSource", () => {
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
order.push("location")
|
||||
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
|
||||
return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -379,9 +379,9 @@ describe("FeedSource", () => {
|
||||
...noActions,
|
||||
async fetchContext(ctx) {
|
||||
order.push("weather")
|
||||
const loc = contextValue(ctx, LocationKey)
|
||||
const loc = ctx.get(LocationKey)
|
||||
expect(loc).toBeDefined()
|
||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
||||
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -400,11 +400,11 @@ describe("FeedSource", () => {
|
||||
const graph = buildGraph([location, weather])
|
||||
const { context } = await refreshGraph(graph)
|
||||
|
||||
expect(contextValue(context, LocationKey)).toEqual({
|
||||
expect(context.get(LocationKey)).toEqual({
|
||||
lat: 51.5,
|
||||
lng: -0.1,
|
||||
})
|
||||
expect(contextValue(context, WeatherKey)).toEqual({
|
||||
expect(context.get(WeatherKey)).toEqual({
|
||||
temperature: 20,
|
||||
condition: "sunny",
|
||||
})
|
||||
@@ -447,12 +447,10 @@ describe("FeedSource", () => {
|
||||
})
|
||||
|
||||
test("source without location context returns empty items", async () => {
|
||||
// Location source exists but hasn't been updated
|
||||
const location: FeedSource = {
|
||||
id: "location",
|
||||
...noActions,
|
||||
async fetchContext() {
|
||||
// Simulate no location available
|
||||
return null
|
||||
},
|
||||
}
|
||||
@@ -462,7 +460,7 @@ describe("FeedSource", () => {
|
||||
const graph = buildGraph([location, weather])
|
||||
const { context, items } = await refreshGraph(graph)
|
||||
|
||||
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
||||
expect(context.get(WeatherKey)).toBeUndefined()
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -476,7 +474,7 @@ describe("FeedSource", () => {
|
||||
() => {
|
||||
updateCount++
|
||||
},
|
||||
() => ({ time: new Date() }),
|
||||
() => new Context(),
|
||||
)
|
||||
|
||||
location.simulateUpdate({ lat: 1, lng: 1 })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ActionDefinition } from "./action"
|
||||
import type { Context } from "./context"
|
||||
import type { Context, ContextEntry } from "./context"
|
||||
import type { FeedItem } from "./feed"
|
||||
|
||||
/**
|
||||
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
||||
* Maps to: source/contextUpdated (notification, source → host)
|
||||
*/
|
||||
onContextUpdate?(
|
||||
callback: (update: Partial<Context>) => void,
|
||||
callback: (entries: readonly ContextEntry[]) => void,
|
||||
getContext: () => Context,
|
||||
): () => void
|
||||
|
||||
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
||||
* Return null if this source cannot provide context.
|
||||
* Maps to: source/fetchContext
|
||||
*/
|
||||
fetchContext(context: Context): Promise<Partial<Context> | null>
|
||||
fetchContext(context: Context): Promise<readonly ContextEntry[] | null>
|
||||
|
||||
/**
|
||||
* Subscribe to reactive feed item updates.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Context } from "@aris/core"
|
||||
import type { ContextKey } from "@aris/core"
|
||||
|
||||
import { Context, contextKey } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||
@@ -15,14 +16,25 @@ const mockCredentials = {
|
||||
serviceId: "mock",
|
||||
}
|
||||
|
||||
interface LocationData {
|
||||
lat: number
|
||||
lng: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
||||
|
||||
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
||||
fetch: async () => response,
|
||||
})
|
||||
|
||||
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
|
||||
time: new Date("2026-01-17T00:00:00Z"),
|
||||
location: location ? { ...location, accuracy: 10 } : undefined,
|
||||
})
|
||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
||||
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
||||
if (location) {
|
||||
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe("WeatherKitDataSource", () => {
|
||||
test("returns empty array when location is missing", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Context, DataSource, FeedItemSignals } from "@aris/core"
|
||||
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core"
|
||||
|
||||
import { TimeRelevance } from "@aris/core"
|
||||
import { TimeRelevance, contextKey } from "@aris/core"
|
||||
|
||||
import {
|
||||
WeatherFeedItemType,
|
||||
@@ -40,6 +40,13 @@ export interface WeatherKitQueryConfig {
|
||||
units?: Units
|
||||
}
|
||||
|
||||
interface LocationData {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
||||
|
||||
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||
@@ -59,7 +66,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
||||
}
|
||||
|
||||
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||
if (!context.location) {
|
||||
const location = context.get(LocationKey)
|
||||
if (!location) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -67,8 +75,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
||||
const timestamp = context.time
|
||||
|
||||
const response = await this.client.fetch({
|
||||
lat: context.location.lat,
|
||||
lng: context.location.lng,
|
||||
lat: location.lat,
|
||||
lng: location.lng,
|
||||
})
|
||||
|
||||
const items: WeatherFeedItem[] = []
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* bun run test-live.ts
|
||||
*/
|
||||
|
||||
import { Context } from "@aris/core"
|
||||
|
||||
import { CalDavSource } from "../src/index.ts"
|
||||
|
||||
const serverUrl = prompt("CalDAV server URL:")
|
||||
@@ -27,7 +29,7 @@ const source = new CalDavSource({
|
||||
lookAheadDays,
|
||||
})
|
||||
|
||||
const context = { time: new Date() }
|
||||
const context = new Context()
|
||||
|
||||
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Context } from "@aris/core"
|
||||
import type { ContextEntry } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, contextValue } from "@aris/core"
|
||||
import { Context, TimeRelevance } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
@@ -13,14 +13,21 @@ import type {
|
||||
} from "./types.ts"
|
||||
|
||||
import { CalDavSource, computeSignals } from "./caldav-source.ts"
|
||||
import { CalDavCalendarKey } from "./calendar-context.ts"
|
||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
|
||||
function loadFixture(name: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||
}
|
||||
|
||||
function createContext(time: Date): Context {
|
||||
return { time }
|
||||
return new Context(time)
|
||||
}
|
||||
|
||||
/** Extract the CalendarContext value from fetchContext entries. */
|
||||
function extractCalendar(entries: readonly ContextEntry[] | null): CalendarContext | undefined {
|
||||
if (!entries) return undefined
|
||||
const entry = entries.find(([key]) => key === CalDavCalendarKey)
|
||||
return entry?.[1] as CalendarContext | undefined
|
||||
}
|
||||
|
||||
class MockDAVClient implements CalDavDAVClient {
|
||||
@@ -302,8 +309,8 @@ describe("CalDavSource.fetchContext", () => {
|
||||
test("returns empty context when no calendars exist", async () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = createSource(client)
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = extractCalendar(entries)
|
||||
|
||||
expect(calendar).toBeDefined()
|
||||
expect(calendar!.inProgress).toEqual([])
|
||||
@@ -320,8 +327,8 @@ describe("CalDavSource.fetchContext", () => {
|
||||
const source = createSource(client)
|
||||
|
||||
// 14:30 is during the 14:00-15:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||
const calendar = extractCalendar(entries)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(1)
|
||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||
@@ -335,8 +342,8 @@ describe("CalDavSource.fetchContext", () => {
|
||||
const source = createSource(client)
|
||||
|
||||
// 12:00 is before the 14:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = extractCalendar(entries)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).not.toBeNull()
|
||||
@@ -350,8 +357,8 @@ describe("CalDavSource.fetchContext", () => {
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = extractCalendar(entries)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).toBeNull()
|
||||
@@ -369,8 +376,8 @@ describe("CalDavSource.fetchContext", () => {
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
||||
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = extractCalendar(entries)
|
||||
|
||||
expect(calendar!.todayEventCount).toBe(2)
|
||||
expect(calendar!.hasTodayEvents).toBe(true)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { DAVClient } from "tsdav"
|
||||
|
||||
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
||||
@@ -93,17 +93,20 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
||||
throw new UnknownActionError(actionId)
|
||||
}
|
||||
|
||||
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
||||
const events = await this.fetchEvents(context)
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
[CalDavCalendarKey]: {
|
||||
return [
|
||||
[
|
||||
CalDavCalendarKey,
|
||||
{
|
||||
inProgress: [],
|
||||
nextEvent: null,
|
||||
hasTodayEvents: false,
|
||||
todayEventCount: 0,
|
||||
},
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
const now = context.time
|
||||
@@ -121,7 +124,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
||||
todayEventCount: events.length,
|
||||
}
|
||||
|
||||
return { [CalDavCalendarKey]: calendarContext }
|
||||
return [[CalDavCalendarKey, calendarContext]]
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
||||
|
||||
@@ -21,4 +21,4 @@ export interface CalendarContext {
|
||||
todayEventCount: number
|
||||
}
|
||||
|
||||
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")
|
||||
export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("aris.caldav", "calendar")
|
||||
|
||||
@@ -10,4 +10,4 @@ export interface NextEvent {
|
||||
location: string | null
|
||||
}
|
||||
|
||||
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")
|
||||
export const NextEventKey: ContextKey<NextEvent> = contextKey("aris.google-calendar", "nextEvent")
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
|
||||
import type {
|
||||
ApiCalendarEvent,
|
||||
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
|
||||
* .register(calendarSource)
|
||||
*
|
||||
* // Access next-event context in downstream sources
|
||||
* const next = contextValue(context, NextEventKey)
|
||||
* const next = context.get(NextEventKey)
|
||||
* if (next && next.minutesUntilStart < 15) {
|
||||
* // remind user
|
||||
* }
|
||||
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
||||
throw new UnknownActionError(actionId)
|
||||
}
|
||||
|
||||
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
||||
const events = await this.fetchAllEvents(context.time)
|
||||
|
||||
const now = context.time.getTime()
|
||||
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
||||
location: nextTimedEvent.location,
|
||||
}
|
||||
|
||||
return { [NextEventKey]: nextEvent }
|
||||
return [[NextEventKey, nextEvent]]
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { LocationKey, LocationSource, type Location } from "./location-source.ts"
|
||||
import type { Location } from "./types.ts"
|
||||
|
||||
import { LocationKey, LocationSource } from "./location-source.ts"
|
||||
|
||||
function createLocation(overrides: Partial<Location> = {}): Location {
|
||||
return {
|
||||
@@ -39,8 +41,8 @@ describe("LocationSource", () => {
|
||||
const location = createLocation()
|
||||
source.pushLocation(location)
|
||||
|
||||
const context = await source.fetchContext()
|
||||
expect(context).toEqual({ [LocationKey]: location })
|
||||
const entries = await source.fetchContext()
|
||||
expect(entries).toEqual([[LocationKey, location]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +67,7 @@ describe("LocationSource", () => {
|
||||
source.pushLocation(location)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith({ [LocationKey]: location })
|
||||
expect(listener).toHaveBeenCalledWith([[LocationKey, location]])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
|
||||
|
||||
import { UnknownActionError, contextKey, type ContextKey } from "@aris/core"
|
||||
import { Context, UnknownActionError, contextKey, type ContextKey } from "@aris/core"
|
||||
import { type } from "arktype"
|
||||
|
||||
import { Location, type LocationSourceOptions } from "./types.ts"
|
||||
|
||||
export const LocationKey: ContextKey<Location> = contextKey("location")
|
||||
export const LocationKey: ContextKey<Location> = contextKey("aris.location", "location")
|
||||
|
||||
/**
|
||||
* A FeedSource that provides location context.
|
||||
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
|
||||
|
||||
private readonly historySize: number
|
||||
private locations: Location[] = []
|
||||
private listeners = new Set<(update: Partial<Context>) => void>()
|
||||
private listeners = new Set<(entries: readonly ContextEntry[]) => void>()
|
||||
|
||||
constructor(options: LocationSourceOptions = {}) {
|
||||
this.historySize = options.historySize ?? 1
|
||||
@@ -59,8 +59,9 @@ export class LocationSource implements FeedSource {
|
||||
if (this.locations.length > this.historySize) {
|
||||
this.locations.shift()
|
||||
}
|
||||
const entries: readonly ContextEntry[] = [[LocationKey, location]]
|
||||
this.listeners.forEach((listener) => {
|
||||
listener({ [LocationKey]: location })
|
||||
listener(entries)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,16 +79,16 @@ export class LocationSource implements FeedSource {
|
||||
return this.locations
|
||||
}
|
||||
|
||||
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
|
||||
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void {
|
||||
this.listeners.add(callback)
|
||||
return () => {
|
||||
this.listeners.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
async fetchContext(): Promise<Partial<Context> | null> {
|
||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||
if (this.lastLocation) {
|
||||
return { [LocationKey]: this.lastLocation }
|
||||
return [[LocationKey, this.lastLocation]]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
import { type } from "arktype"
|
||||
|
||||
@@ -112,7 +112,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchContext(): Promise<null> {
|
||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
this.client.fetchStations(),
|
||||
])
|
||||
|
||||
const location = contextValue(context, LocationKey)
|
||||
const location = context.get(LocationKey)
|
||||
|
||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||
const closestStationDistance = location
|
||||
|
||||
@@ -24,4 +24,4 @@ export interface Weather {
|
||||
daylight: boolean
|
||||
}
|
||||
|
||||
export const WeatherKey: ContextKey<Weather> = contextKey("weather")
|
||||
export const WeatherKey: ContextKey<Weather> = contextKey("aris.weather", "weather")
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
|
||||
import { TimeRelevance, UnknownActionError, contextValue } from "@aris/core"
|
||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { LocationKey } from "@aris/source-location"
|
||||
|
||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||
@@ -86,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
||||
* })
|
||||
*
|
||||
* // Access weather context in downstream sources
|
||||
* const weather = contextValue(context, WeatherKey)
|
||||
* const weather = context.get(WeatherKey)
|
||||
* if (weather?.condition === "Rain") {
|
||||
* // suggest umbrella
|
||||
* }
|
||||
@@ -119,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
||||
throw new UnknownActionError(actionId)
|
||||
}
|
||||
|
||||
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
||||
const location = contextValue(context, LocationKey)
|
||||
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
||||
const location = context.get(LocationKey)
|
||||
if (!location) {
|
||||
return null
|
||||
}
|
||||
@@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
||||
daylight: response.currentWeather.daylight,
|
||||
}
|
||||
|
||||
return { [WeatherKey]: weather }
|
||||
return [[WeatherKey, weather]]
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
||||
const location = contextValue(context, LocationKey)
|
||||
const location = context.get(LocationKey)
|
||||
if (!location) {
|
||||
return []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user