mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 09:01:19 +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 { describe, expect, test } from "bun:test"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -27,7 +27,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|||||||
async executeAction(): Promise<unknown> {
|
async executeAction(): Promise<unknown> {
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<Partial<Context> | null> {
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
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 { LocationSource } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
@@ -14,7 +14,7 @@ function createStubSource(id: string): FeedSource {
|
|||||||
async executeAction(): Promise<unknown> {
|
async executeAction(): Promise<unknown> {
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<Partial<Context> | null> {
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Context } from "./context"
|
import type { ContextEntry } from "./context"
|
||||||
import type { ContextProvider } from "./context-provider"
|
import type { ContextProvider } from "./context-provider"
|
||||||
|
|
||||||
|
import { contextKey } from "./context"
|
||||||
|
|
||||||
interface ContextUpdatable {
|
interface ContextUpdatable {
|
||||||
pushContextUpdate(update: Partial<Context>): void
|
pushContextUpdate(entries: readonly ContextEntry[]): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderError {
|
export interface ProviderError {
|
||||||
@@ -54,7 +56,7 @@ export class ContextBridge {
|
|||||||
this.providers.set(provider.key, provider as ContextProvider)
|
this.providers.set(provider.key, provider as ContextProvider)
|
||||||
|
|
||||||
const cleanup = provider.onUpdate((value) => {
|
const cleanup = provider.onUpdate((value) => {
|
||||||
this.controller.pushContextUpdate({ [provider.key]: value })
|
this.controller.pushContextUpdate([[contextKey(provider.key), value]])
|
||||||
})
|
})
|
||||||
this.cleanups.push(cleanup)
|
this.cleanups.push(cleanup)
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ export class ContextBridge {
|
|||||||
* Returns errors from providers that failed to fetch.
|
* Returns errors from providers that failed to fetch.
|
||||||
*/
|
*/
|
||||||
async refresh(): Promise<RefreshResult> {
|
async refresh(): Promise<RefreshResult> {
|
||||||
const updates: Partial<Context> = {}
|
const collected: ContextEntry[] = []
|
||||||
const errors: ProviderError[] = []
|
const errors: ProviderError[] = []
|
||||||
|
|
||||||
const entries = Array.from(this.providers.entries())
|
const entries = Array.from(this.providers.entries())
|
||||||
@@ -78,7 +80,7 @@ export class ContextBridge {
|
|||||||
entries.forEach(([key], i) => {
|
entries.forEach(([key], i) => {
|
||||||
const result = results[i]
|
const result = results[i]
|
||||||
if (result?.status === "fulfilled") {
|
if (result?.status === "fulfilled") {
|
||||||
updates[key] = result.value
|
collected.push([contextKey(key), result.value])
|
||||||
} else if (result?.status === "rejected") {
|
} else if (result?.status === "rejected") {
|
||||||
errors.push({
|
errors.push({
|
||||||
key,
|
key,
|
||||||
@@ -87,7 +89,7 @@ export class ContextBridge {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.controller.pushContextUpdate(updates)
|
this.controller.pushContextUpdate(collected)
|
||||||
|
|
||||||
return { errors }
|
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:
|
* Context keys are arrays that form a hierarchy. Sources write to specific
|
||||||
* ```ts
|
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
|
||||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
* 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 }
|
|
||||||
|
|
||||||
/**
|
// -- Key types --
|
||||||
* Creates a typed context key.
|
|
||||||
*
|
/** A single segment of a context key: string, number, or a record of primitives. */
|
||||||
* @example
|
export type ContextKeyPart = string | number | Record<string, unknown>
|
||||||
* ```ts
|
|
||||||
* interface Location { lat: number; lng: number; accuracy: number }
|
/** A context key is a readonly tuple of parts, branded with the value type. */
|
||||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
|
||||||
* ```
|
|
||||||
*/
|
/** Creates a typed context key. */
|
||||||
export function contextKey<T>(key: string): ContextKey<T> {
|
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
|
||||||
return key as ContextKey<T>
|
return parts as ContextKey<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -- Serialization --
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arbitrary key-value bag representing the current state.
|
* Deterministic serialization of a context key for use as a Map key.
|
||||||
* Always includes `time`. Other keys are added by context providers.
|
* 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
|
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"
|
* readonly type = "weather"
|
||||||
*
|
*
|
||||||
* async query(context: Context): Promise<WeatherItem[]> {
|
* async query(context: Context): Promise<WeatherItem[]> {
|
||||||
* const location = contextValue(context, LocationKey)
|
* const location = context.get(LocationKey)
|
||||||
* if (!location) return []
|
* if (!location) return []
|
||||||
* const data = await fetchWeather(location)
|
* const data = await fetchWeather(location)
|
||||||
* return [{
|
* return [{
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Context } from "./context"
|
import type { ContextEntry } from "./context"
|
||||||
import type { DataSource } from "./data-source"
|
import type { DataSource } from "./data-source"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
import type { ReconcileResult } from "./reconciler"
|
import type { ReconcileResult } from "./reconciler"
|
||||||
|
|
||||||
|
import { Context } from "./context"
|
||||||
import { Reconciler } from "./reconciler"
|
import { Reconciler } from "./reconciler"
|
||||||
|
|
||||||
export interface FeedControllerConfig {
|
export interface FeedControllerConfig {
|
||||||
@@ -40,7 +41,7 @@ const DEFAULT_DEBOUNCE_MS = 100
|
|||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Context update triggers debounced reconcile
|
* // Context update triggers debounced reconcile
|
||||||
* controller.pushContextUpdate({ [LocationKey]: location })
|
* controller.pushContextUpdate([[LocationKey, location]])
|
||||||
*
|
*
|
||||||
* // Direct reconcile (no debounce)
|
* // Direct reconcile (no debounce)
|
||||||
* const result = await controller.reconcile()
|
* const result = await controller.reconcile()
|
||||||
@@ -59,7 +60,7 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
private stopped = false
|
private stopped = false
|
||||||
|
|
||||||
constructor(config?: FeedControllerConfig) {
|
constructor(config?: FeedControllerConfig) {
|
||||||
this.context = config?.initialContext ?? { time: new Date() }
|
this.context = config?.initialContext ?? new Context()
|
||||||
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
||||||
this.timeout = config?.timeout
|
this.timeout = config?.timeout
|
||||||
}
|
}
|
||||||
@@ -94,9 +95,10 @@ export class FeedController<TItems extends FeedItem = never> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merges update into context and schedules a debounced reconcile. */
|
/** Merges entries into context and schedules a debounced reconcile. */
|
||||||
pushContextUpdate(update: Partial<Context>): void {
|
pushContextUpdate(entries: readonly ContextEntry[]): void {
|
||||||
this.context = { ...this.context, ...update, time: new Date() }
|
this.context.time = new Date()
|
||||||
|
this.context.set(entries)
|
||||||
this.scheduleReconcile()
|
this.scheduleReconcile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
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 { 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
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -48,7 +48,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
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 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -63,12 +63,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return { [LocationKey]: currentLocation }
|
return [[LocationKey, currentLocation]]
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.({ [LocationKey]: location })
|
callback?.([[LocationKey, location]])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,15 +85,15 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = contextValue(context, LocationKey)
|
const location = context.get(LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return { [WeatherKey]: weather }
|
return [[WeatherKey, weather]]
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -123,7 +123,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -265,7 +265,7 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
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,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = contextValue(ctx, LocationKey)
|
const loc = ctx.get(LocationKey)
|
||||||
expect(loc).toBeDefined()
|
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()
|
const { context } = await engine.refresh()
|
||||||
|
|
||||||
expect(contextValue(context, LocationKey)).toEqual({
|
expect(context.get(LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(contextValue(context, WeatherKey)).toEqual({
|
expect(context.get(WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -361,7 +361,7 @@ describe("FeedEngine", () => {
|
|||||||
|
|
||||||
const { context, items } = await engine.refresh()
|
const { context, items } = await engine.refresh()
|
||||||
|
|
||||||
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
expect(context.get(WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ describe("FeedEngine", () => {
|
|||||||
await engine.refresh()
|
await engine.refresh()
|
||||||
|
|
||||||
const context = engine.currentContext()
|
const context = engine.currentContext()
|
||||||
expect(contextValue(context, LocationKey)).toEqual({
|
expect(context.get(LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
@@ -734,7 +734,7 @@ describe("FeedEngine", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("reactive item update refreshes cache", async () => {
|
test("reactive item update refreshes cache", async () => {
|
||||||
let itemUpdateCallback: (() => void) | null = null
|
let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null
|
||||||
|
|
||||||
const source: FeedSource = {
|
const source: FeedSource = {
|
||||||
id: "reactive-items",
|
id: "reactive-items",
|
||||||
@@ -765,7 +765,7 @@ describe("FeedEngine", () => {
|
|||||||
engine.start()
|
engine.start()
|
||||||
|
|
||||||
// Trigger item update
|
// Trigger item update
|
||||||
itemUpdateCallback!()
|
itemUpdateCallback!([])
|
||||||
|
|
||||||
// Wait for async refresh
|
// Wait for async refresh
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
@@ -885,12 +885,12 @@ describe("FeedEngine", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
fetchCount++
|
fetchCount++
|
||||||
const loc = contextValue(ctx, LocationKey)
|
const loc = ctx.get(LocationKey)
|
||||||
if (!loc) return null
|
if (!loc) return null
|
||||||
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
|
return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
|
||||||
},
|
},
|
||||||
async fetchItems(ctx) {
|
async fetchItems(ctx) {
|
||||||
const weather = contextValue(ctx, WeatherKey)
|
const weather = ctx.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context } from "./context"
|
import type { ContextEntry } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
|
||||||
import type { FeedSource } from "./feed-source"
|
import type { FeedSource } from "./feed-source"
|
||||||
|
|
||||||
|
import { Context } from "./context"
|
||||||
|
|
||||||
export interface SourceError {
|
export interface SourceError {
|
||||||
sourceId: string
|
sourceId: string
|
||||||
error: Error
|
error: Error
|
||||||
@@ -65,7 +67,7 @@ interface SourceGraph {
|
|||||||
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
private graph: SourceGraph | null = null
|
private graph: SourceGraph | null = null
|
||||||
private context: Context = { time: new Date() }
|
private context: Context = new Context()
|
||||||
private subscribers = new Set<FeedSubscriber<TItems>>()
|
private subscribers = new Set<FeedSubscriber<TItems>>()
|
||||||
private cleanups: Array<() => void> = []
|
private cleanups: Array<() => void> = []
|
||||||
private started = false
|
private started = false
|
||||||
@@ -138,14 +140,14 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const errors: SourceError[] = []
|
const errors: SourceError[] = []
|
||||||
|
|
||||||
// Reset context with fresh time
|
// Reset context with fresh time
|
||||||
let context: Context = { time: new Date() }
|
const context = new Context()
|
||||||
|
|
||||||
// Run fetchContext in topological order
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
try {
|
try {
|
||||||
const update = await source.fetchContext(context)
|
const entries = await source.fetchContext(context)
|
||||||
if (update) {
|
if (entries) {
|
||||||
context = { ...context, ...update }
|
context.set(entries)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -213,8 +215,8 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
if (source.onContextUpdate) {
|
if (source.onContextUpdate) {
|
||||||
const cleanup = source.onContextUpdate(
|
const cleanup = source.onContextUpdate(
|
||||||
(update) => {
|
(entries) => {
|
||||||
this.handleContextUpdate(source.id, update)
|
this.handleContextUpdate(source.id, entries)
|
||||||
},
|
},
|
||||||
() => this.context,
|
() => this.context,
|
||||||
)
|
)
|
||||||
@@ -365,8 +367,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
return this.graph
|
return this.graph
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
|
private handleContextUpdate(sourceId: string, entries: readonly ContextEntry[]): void {
|
||||||
this.context = { ...this.context, ...update, time: new Date() }
|
this.context.time = new Date()
|
||||||
|
this.context.set(entries)
|
||||||
|
|
||||||
// Re-run dependents and notify
|
// Re-run dependents and notify
|
||||||
this.refreshDependents(sourceId)
|
this.refreshDependents(sourceId)
|
||||||
@@ -381,9 +384,9 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
|||||||
const source = graph.sources.get(id)
|
const source = graph.sources.get(id)
|
||||||
if (source) {
|
if (source) {
|
||||||
try {
|
try {
|
||||||
const update = await source.fetchContext(this.context)
|
const entries = await source.fetchContext(this.context)
|
||||||
if (update) {
|
if (entries) {
|
||||||
this.context = { ...this.context, ...update }
|
this.context.set(entries)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Errors during reactive updates are logged but don't stop propagation
|
// Errors during reactive updates are logged but don't stop propagation
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test"
|
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 { FeedEngine } from "./feed-engine"
|
||||||
import { UnknownActionError } from "./index"
|
import { UnknownActionError } from "./index"
|
||||||
@@ -471,7 +477,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
test("post-processors run during reactive context updates", async () => {
|
test("post-processors run during reactive context updates", async () => {
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
|
|
||||||
let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
|
let triggerUpdate: ((entries: readonly ContextEntry[]) => void) | null = null
|
||||||
|
|
||||||
const source: FeedSource = {
|
const source: FeedSource = {
|
||||||
id: "aris.reactive",
|
id: "aris.reactive",
|
||||||
@@ -502,7 +508,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
const countAfterStart = callCount
|
const countAfterStart = callCount
|
||||||
|
|
||||||
// Trigger a reactive context update
|
// Trigger a reactive context update
|
||||||
triggerUpdate!({ foo: "bar" })
|
triggerUpdate!([])
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
@@ -513,7 +519,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
test("post-processors run during reactive item updates", async () => {
|
test("post-processors run during reactive item updates", async () => {
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
|
|
||||||
let triggerItemsUpdate: (() => void) | null = null
|
let triggerItemsUpdate: ((items: FeedItem[]) => void) | null = null
|
||||||
|
|
||||||
const source: FeedSource = {
|
const source: FeedSource = {
|
||||||
id: "aris.reactive",
|
id: "aris.reactive",
|
||||||
@@ -543,7 +549,7 @@ describe("FeedPostProcessor", () => {
|
|||||||
const countAfterStart = callCount
|
const countAfterStart = callCount
|
||||||
|
|
||||||
// Trigger a reactive items update
|
// Trigger a reactive items update
|
||||||
triggerItemsUpdate!()
|
triggerItemsUpdate!([weatherItem("w1", 25)])
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
expect(callCount).toBeGreaterThan(countAfterStart)
|
expect(callCount).toBeGreaterThan(countAfterStart)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
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
|
// No-op action methods for test sources
|
||||||
const noActions = {
|
const noActions = {
|
||||||
@@ -47,7 +47,7 @@ interface SimulatedLocationSource extends FeedSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createLocationSource(): SimulatedLocationSource {
|
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 }
|
let currentLocation: Location = { lat: 0, lng: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -62,12 +62,12 @@ function createLocationSource(): SimulatedLocationSource {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
return { [LocationKey]: currentLocation }
|
return [[LocationKey, currentLocation]]
|
||||||
},
|
},
|
||||||
|
|
||||||
simulateUpdate(location: Location) {
|
simulateUpdate(location: Location) {
|
||||||
currentLocation = location
|
currentLocation = location
|
||||||
callback?.({ [LocationKey]: location })
|
callback?.([[LocationKey, location]])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,15 +84,15 @@ function createWeatherSource(
|
|||||||
...noActions,
|
...noActions,
|
||||||
|
|
||||||
async fetchContext(context) {
|
async fetchContext(context) {
|
||||||
const location = contextValue(context, LocationKey)
|
const location = context.get(LocationKey)
|
||||||
if (!location) return null
|
if (!location) return null
|
||||||
|
|
||||||
const weather = await fetchWeather(location)
|
const weather = await fetchWeather(location)
|
||||||
return { [WeatherKey]: weather }
|
return [[WeatherKey, weather]]
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -122,7 +122,7 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchItems(context) {
|
async fetchItems(context) {
|
||||||
const weather = contextValue(context, WeatherKey)
|
const weather = context.get(WeatherKey)
|
||||||
if (!weather) return []
|
if (!weather) return []
|
||||||
|
|
||||||
if (weather.condition === "storm") {
|
if (weather.condition === "storm") {
|
||||||
@@ -207,13 +207,13 @@ function buildGraph(sources: FeedSource[]): SourceGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
|
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
|
// Run fetchContext in topological order
|
||||||
for (const source of graph.sorted) {
|
for (const source of graph.sorted) {
|
||||||
const update = await source.fetchContext(context)
|
const entries = await source.fetchContext(context)
|
||||||
if (update) {
|
if (entries) {
|
||||||
context = { ...context, ...update }
|
context.set(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ describe("FeedSource", () => {
|
|||||||
|
|
||||||
test("source without context returns null from fetchContext", async () => {
|
test("source without context returns null from fetchContext", async () => {
|
||||||
const source = createAlertSource()
|
const source = createAlertSource()
|
||||||
const result = await source.fetchContext({ time: new Date() })
|
const result = await source.fetchContext(new Context())
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -369,7 +369,7 @@ describe("FeedSource", () => {
|
|||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
order.push("location")
|
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,
|
...noActions,
|
||||||
async fetchContext(ctx) {
|
async fetchContext(ctx) {
|
||||||
order.push("weather")
|
order.push("weather")
|
||||||
const loc = contextValue(ctx, LocationKey)
|
const loc = ctx.get(LocationKey)
|
||||||
expect(loc).toBeDefined()
|
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 graph = buildGraph([location, weather])
|
||||||
const { context } = await refreshGraph(graph)
|
const { context } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(contextValue(context, LocationKey)).toEqual({
|
expect(context.get(LocationKey)).toEqual({
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
})
|
})
|
||||||
expect(contextValue(context, WeatherKey)).toEqual({
|
expect(context.get(WeatherKey)).toEqual({
|
||||||
temperature: 20,
|
temperature: 20,
|
||||||
condition: "sunny",
|
condition: "sunny",
|
||||||
})
|
})
|
||||||
@@ -447,12 +447,10 @@ describe("FeedSource", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("source without location context returns empty items", async () => {
|
test("source without location context returns empty items", async () => {
|
||||||
// Location source exists but hasn't been updated
|
|
||||||
const location: FeedSource = {
|
const location: FeedSource = {
|
||||||
id: "location",
|
id: "location",
|
||||||
...noActions,
|
...noActions,
|
||||||
async fetchContext() {
|
async fetchContext() {
|
||||||
// Simulate no location available
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -462,7 +460,7 @@ describe("FeedSource", () => {
|
|||||||
const graph = buildGraph([location, weather])
|
const graph = buildGraph([location, weather])
|
||||||
const { context, items } = await refreshGraph(graph)
|
const { context, items } = await refreshGraph(graph)
|
||||||
|
|
||||||
expect(contextValue(context, WeatherKey)).toBeUndefined()
|
expect(context.get(WeatherKey)).toBeUndefined()
|
||||||
expect(items).toHaveLength(0)
|
expect(items).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -476,7 +474,7 @@ describe("FeedSource", () => {
|
|||||||
() => {
|
() => {
|
||||||
updateCount++
|
updateCount++
|
||||||
},
|
},
|
||||||
() => ({ time: new Date() }),
|
() => new Context(),
|
||||||
)
|
)
|
||||||
|
|
||||||
location.simulateUpdate({ lat: 1, lng: 1 })
|
location.simulateUpdate({ lat: 1, lng: 1 })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ActionDefinition } from "./action"
|
import type { ActionDefinition } from "./action"
|
||||||
import type { Context } from "./context"
|
import type { Context, ContextEntry } from "./context"
|
||||||
import type { FeedItem } from "./feed"
|
import type { FeedItem } from "./feed"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Maps to: source/contextUpdated (notification, source → host)
|
* Maps to: source/contextUpdated (notification, source → host)
|
||||||
*/
|
*/
|
||||||
onContextUpdate?(
|
onContextUpdate?(
|
||||||
callback: (update: Partial<Context>) => void,
|
callback: (entries: readonly ContextEntry[]) => void,
|
||||||
getContext: () => Context,
|
getContext: () => Context,
|
||||||
): () => void
|
): () => void
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export interface FeedSource<TItem extends FeedItem = FeedItem> {
|
|||||||
* Return null if this source cannot provide context.
|
* Return null if this source cannot provide context.
|
||||||
* Maps to: source/fetchContext
|
* Maps to: source/fetchContext
|
||||||
*/
|
*/
|
||||||
fetchContext(context: Context): Promise<Partial<Context> | null>
|
fetchContext(context: Context): Promise<readonly ContextEntry[] | null>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to reactive feed item updates.
|
* Subscribe to reactive feed item updates.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Context
|
// Context
|
||||||
export type { Context, ContextKey } from "./context"
|
export type { ContextEntry, ContextKey, ContextKeyPart } from "./context"
|
||||||
export { contextKey, contextValue } from "./context"
|
export { Context, contextKey, serializeKey } from "./context"
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
export type { ActionDefinition } from "./action"
|
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 { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
|
||||||
@@ -15,14 +16,25 @@ const mockCredentials = {
|
|||||||
serviceId: "mock",
|
serviceId: "mock",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
accuracy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
||||||
|
|
||||||
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
|
||||||
fetch: async () => response,
|
fetch: async () => response,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
|
function createMockContext(location?: { lat: number; lng: number }): Context {
|
||||||
time: new Date("2026-01-17T00:00:00Z"),
|
const ctx = new Context(new Date("2026-01-17T00:00:00Z"))
|
||||||
location: location ? { ...location, accuracy: 10 } : undefined,
|
if (location) {
|
||||||
})
|
ctx.set([[LocationKey, { ...location, accuracy: 10 }]])
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
describe("WeatherKitDataSource", () => {
|
describe("WeatherKitDataSource", () => {
|
||||||
test("returns empty array when location is missing", async () => {
|
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 {
|
import {
|
||||||
WeatherFeedItemType,
|
WeatherFeedItemType,
|
||||||
@@ -40,6 +40,13 @@ export interface WeatherKitQueryConfig {
|
|||||||
units?: Units
|
units?: Units
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
||||||
|
|
||||||
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||||
private readonly DEFAULT_HOURLY_LIMIT = 12
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||||
private readonly DEFAULT_DAILY_LIMIT = 7
|
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[]> {
|
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
||||||
if (!context.location) {
|
const location = context.get(LocationKey)
|
||||||
|
if (!location) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +75,8 @@ export class WeatherKitDataSource implements DataSource<WeatherFeedItem, Weather
|
|||||||
const timestamp = context.time
|
const timestamp = context.time
|
||||||
|
|
||||||
const response = await this.client.fetch({
|
const response = await this.client.fetch({
|
||||||
lat: context.location.lat,
|
lat: location.lat,
|
||||||
lng: context.location.lng,
|
lng: location.lng,
|
||||||
})
|
})
|
||||||
|
|
||||||
const items: WeatherFeedItem[] = []
|
const items: WeatherFeedItem[] = []
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* bun run test-live.ts
|
* bun run test-live.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Context } from "@aris/core"
|
||||||
|
|
||||||
import { CalDavSource } from "../src/index.ts"
|
import { CalDavSource } from "../src/index.ts"
|
||||||
|
|
||||||
const serverUrl = prompt("CalDAV server URL:")
|
const serverUrl = prompt("CalDAV server URL:")
|
||||||
@@ -27,7 +29,7 @@ const source = new CalDavSource({
|
|||||||
lookAheadDays,
|
lookAheadDays,
|
||||||
})
|
})
|
||||||
|
|
||||||
const context = { time: new Date() }
|
const context = new Context()
|
||||||
|
|
||||||
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
|
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 { describe, expect, test } from "bun:test"
|
||||||
import { readFileSync } from "node:fs"
|
import { readFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
@@ -13,14 +13,21 @@ import type {
|
|||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
|
|
||||||
import { CalDavSource, computeSignals } from "./caldav-source.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 {
|
function loadFixture(name: string): string {
|
||||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
function createContext(time: Date): Context {
|
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 {
|
class MockDAVClient implements CalDavDAVClient {
|
||||||
@@ -302,8 +309,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
test("returns empty context when no calendars exist", async () => {
|
test("returns empty context when no calendars exist", async () => {
|
||||||
const client = new MockDAVClient([], {})
|
const client = new MockDAVClient([], {})
|
||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
expect(calendar).toBeDefined()
|
expect(calendar).toBeDefined()
|
||||||
expect(calendar!.inProgress).toEqual([])
|
expect(calendar!.inProgress).toEqual([])
|
||||||
@@ -320,8 +327,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
// 14:30 is during the 14:00-15:00 event
|
// 14:30 is during the 14:00-15:00 event
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(1)
|
expect(calendar!.inProgress).toHaveLength(1)
|
||||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||||
@@ -335,8 +342,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
// 12:00 is before the 14:00 event
|
// 12:00 is before the 14:00 event
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
expect(calendar!.nextEvent).not.toBeNull()
|
expect(calendar!.nextEvent).not.toBeNull()
|
||||||
@@ -350,8 +357,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
expect(calendar!.inProgress).toHaveLength(0)
|
expect(calendar!.inProgress).toHaveLength(0)
|
||||||
expect(calendar!.nextEvent).toBeNull()
|
expect(calendar!.nextEvent).toBeNull()
|
||||||
@@ -369,8 +376,8 @@ describe("CalDavSource.fetchContext", () => {
|
|||||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||||
const source = createSource(client)
|
const source = createSource(client)
|
||||||
|
|
||||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
const entries = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||||
const calendar = contextValue(ctx as Context, CalDavCalendarKey)
|
const calendar = extractCalendar(entries)
|
||||||
|
|
||||||
expect(calendar!.todayEventCount).toBe(2)
|
expect(calendar!.todayEventCount).toBe(2)
|
||||||
expect(calendar!.hasTodayEvents).toBe(true)
|
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 { DAVClient } from "tsdav"
|
||||||
|
|
||||||
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
|
||||||
@@ -93,17 +93,20 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
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)
|
const events = await this.fetchEvents(context)
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return {
|
return [
|
||||||
[CalDavCalendarKey]: {
|
[
|
||||||
inProgress: [],
|
CalDavCalendarKey,
|
||||||
nextEvent: null,
|
{
|
||||||
hasTodayEvents: false,
|
inProgress: [],
|
||||||
todayEventCount: 0,
|
nextEvent: null,
|
||||||
},
|
hasTodayEvents: false,
|
||||||
}
|
todayEventCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = context.time
|
const now = context.time
|
||||||
@@ -121,7 +124,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
|||||||
todayEventCount: events.length,
|
todayEventCount: events.length,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { [CalDavCalendarKey]: calendarContext }
|
return [[CalDavCalendarKey, calendarContext]]
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ export interface CalendarContext {
|
|||||||
todayEventCount: number
|
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
|
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 { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
||||||
|
|
||||||
import fixture from "../fixtures/events.json"
|
import fixture from "../fixtures/events.json"
|
||||||
import { NextEventKey } from "./calendar-context"
|
import { NextEventKey, type NextEvent } from "./calendar-context"
|
||||||
import { CalendarFeedItemType } from "./feed-items"
|
import { CalendarFeedItemType } from "./feed-items"
|
||||||
import { GoogleCalendarSource } from "./google-calendar-source"
|
import { GoogleCalendarSource } from "./google-calendar-source"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function defaultMockClient(): GoogleCalendarClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createContext(time?: Date): Context {
|
function createContext(time?: Date): Context {
|
||||||
return { time: time ?? NOW }
|
return new Context(time ?? NOW)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("GoogleCalendarSource", () => {
|
describe("GoogleCalendarSource", () => {
|
||||||
@@ -229,15 +229,16 @@ describe("GoogleCalendarSource", () => {
|
|||||||
|
|
||||||
test("returns next upcoming timed event (not ongoing)", async () => {
|
test("returns next upcoming timed event (not ongoing)", async () => {
|
||||||
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||||||
const result = await source.fetchContext(createContext())
|
const entries = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
expect(entries).not.toBeNull()
|
||||||
const nextEvent = contextValue(result! as Context, NextEventKey)
|
expect(entries).toHaveLength(1)
|
||||||
expect(nextEvent).toBeDefined()
|
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
|
// evt-soon starts at 10:10, which is the nearest future timed event
|
||||||
expect(nextEvent!.title).toBe("1:1 with Manager")
|
expect(nextEvent.title).toBe("1:1 with Manager")
|
||||||
expect(nextEvent!.minutesUntilStart).toBe(10)
|
expect(nextEvent.minutesUntilStart).toBe(10)
|
||||||
expect(nextEvent!.location).toBeNull()
|
expect(nextEvent.location).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes location when available", async () => {
|
test("includes location when available", async () => {
|
||||||
@@ -255,12 +256,11 @@ describe("GoogleCalendarSource", () => {
|
|||||||
const source = new GoogleCalendarSource({
|
const source = new GoogleCalendarSource({
|
||||||
client: createMockClient({ primary: events }),
|
client: createMockClient({ primary: events }),
|
||||||
})
|
})
|
||||||
const result = await source.fetchContext(createContext())
|
const entries = await source.fetchContext(createContext())
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
expect(entries).not.toBeNull()
|
||||||
const nextEvent = contextValue(result! as Context, NextEventKey)
|
const [, nextEvent] = entries![0]! as [typeof NextEventKey, NextEvent]
|
||||||
expect(nextEvent).toBeDefined()
|
expect(nextEvent.location).toBe("123 Main St")
|
||||||
expect(nextEvent!.location).toBe("123 Main St")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips ongoing events for next-event context", async () => {
|
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 {
|
import type {
|
||||||
ApiCalendarEvent,
|
ApiCalendarEvent,
|
||||||
@@ -58,7 +58,7 @@ const URGENCY_ALL_DAY = 0.4
|
|||||||
* .register(calendarSource)
|
* .register(calendarSource)
|
||||||
*
|
*
|
||||||
* // Access next-event context in downstream sources
|
* // Access next-event context in downstream sources
|
||||||
* const next = contextValue(context, NextEventKey)
|
* const next = context.get(NextEventKey)
|
||||||
* if (next && next.minutesUntilStart < 15) {
|
* if (next && next.minutesUntilStart < 15) {
|
||||||
* // remind user
|
* // remind user
|
||||||
* }
|
* }
|
||||||
@@ -85,7 +85,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
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 events = await this.fetchAllEvents(context.time)
|
||||||
|
|
||||||
const now = context.time.getTime()
|
const now = context.time.getTime()
|
||||||
@@ -105,7 +105,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
location: nextTimedEvent.location,
|
location: nextTimedEvent.location,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { [NextEventKey]: nextEvent }
|
return [[NextEventKey, nextEvent]]
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test"
|
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 {
|
function createLocation(overrides: Partial<Location> = {}): Location {
|
||||||
return {
|
return {
|
||||||
@@ -39,8 +41,8 @@ describe("LocationSource", () => {
|
|||||||
const location = createLocation()
|
const location = createLocation()
|
||||||
source.pushLocation(location)
|
source.pushLocation(location)
|
||||||
|
|
||||||
const context = await source.fetchContext()
|
const entries = await source.fetchContext()
|
||||||
expect(context).toEqual({ [LocationKey]: location })
|
expect(entries).toEqual([[LocationKey, location]])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ describe("LocationSource", () => {
|
|||||||
source.pushLocation(location)
|
source.pushLocation(location)
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1)
|
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 { type } from "arktype"
|
||||||
|
|
||||||
import { Location, type LocationSourceOptions } from "./types.ts"
|
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.
|
* A FeedSource that provides location context.
|
||||||
@@ -20,7 +20,7 @@ export class LocationSource implements FeedSource {
|
|||||||
|
|
||||||
private readonly historySize: number
|
private readonly historySize: number
|
||||||
private locations: Location[] = []
|
private locations: Location[] = []
|
||||||
private listeners = new Set<(update: Partial<Context>) => void>()
|
private listeners = new Set<(entries: readonly ContextEntry[]) => void>()
|
||||||
|
|
||||||
constructor(options: LocationSourceOptions = {}) {
|
constructor(options: LocationSourceOptions = {}) {
|
||||||
this.historySize = options.historySize ?? 1
|
this.historySize = options.historySize ?? 1
|
||||||
@@ -59,8 +59,9 @@ export class LocationSource implements FeedSource {
|
|||||||
if (this.locations.length > this.historySize) {
|
if (this.locations.length > this.historySize) {
|
||||||
this.locations.shift()
|
this.locations.shift()
|
||||||
}
|
}
|
||||||
|
const entries: readonly ContextEntry[] = [[LocationKey, location]]
|
||||||
this.listeners.forEach((listener) => {
|
this.listeners.forEach((listener) => {
|
||||||
listener({ [LocationKey]: location })
|
listener(entries)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,16 +79,16 @@ export class LocationSource implements FeedSource {
|
|||||||
return this.locations
|
return this.locations
|
||||||
}
|
}
|
||||||
|
|
||||||
onContextUpdate(callback: (update: Partial<Context>) => void): () => void {
|
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void {
|
||||||
this.listeners.add(callback)
|
this.listeners.add(callback)
|
||||||
return () => {
|
return () => {
|
||||||
this.listeners.delete(callback)
|
this.listeners.delete(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(): Promise<Partial<Context> | null> {
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
if (this.lastLocation) {
|
if (this.lastLocation) {
|
||||||
return { [LocationKey]: this.lastLocation }
|
return [[LocationKey, this.lastLocation]]
|
||||||
}
|
}
|
||||||
return null
|
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 { LocationKey, type Location } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
@@ -81,9 +80,9 @@ class FixtureTflApi implements ITflApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createContext(location?: Location): Context {
|
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) {
|
if (location) {
|
||||||
ctx[LocationKey] = location
|
ctx.set([[LocationKey, location]])
|
||||||
}
|
}
|
||||||
return ctx
|
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 { LocationKey } from "@aris/source-location"
|
||||||
import { type } from "arktype"
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
this.client.fetchStations(),
|
this.client.fetchStations(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const location = contextValue(context, LocationKey)
|
const location = context.get(LocationKey)
|
||||||
|
|
||||||
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
const items: TflAlertFeedItem[] = statuses.map((status) => {
|
||||||
const closestStationDistance = location
|
const closestStationDistance = location
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export interface Weather {
|
|||||||
daylight: boolean
|
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 { LocationKey } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
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 fixture from "../fixtures/san-francisco.json"
|
||||||
import { WeatherFeedItemType } from "./feed-items"
|
import { WeatherFeedItemType } from "./feed-items"
|
||||||
import { WeatherKey } from "./weather-context"
|
import { WeatherKey, type Weather } from "./weather-context"
|
||||||
import { WeatherSource, Units } from "./weather-source"
|
import { WeatherSource, Units } from "./weather-source"
|
||||||
|
|
||||||
const mockCredentials = {
|
const mockCredentials = {
|
||||||
@@ -23,9 +25,9 @@ function createMockClient(response: WeatherKitResponse): WeatherKitClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext(location?: { lat: number; lng: number }): Context {
|
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) {
|
if (location) {
|
||||||
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
|
ctx.set([[LocationKey, { ...location, accuracy: 10, timestamp: new Date() }]])
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -63,18 +65,19 @@ describe("WeatherSource", () => {
|
|||||||
const source = new WeatherSource({ client: mockClient })
|
const source = new WeatherSource({ client: mockClient })
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const result = await source.fetchContext(context)
|
const entries = await source.fetchContext(context)
|
||||||
expect(result).not.toBeNull()
|
expect(entries).not.toBeNull()
|
||||||
const weather = contextValue(result! as Context, WeatherKey)
|
expect(entries).toHaveLength(1)
|
||||||
|
|
||||||
expect(weather).toBeDefined()
|
const [key, weather] = entries![0]! as [typeof WeatherKey, Weather]
|
||||||
expect(typeof weather!.temperature).toBe("number")
|
expect(key).toEqual(WeatherKey)
|
||||||
expect(typeof weather!.temperatureApparent).toBe("number")
|
expect(typeof weather.temperature).toBe("number")
|
||||||
expect(typeof weather!.condition).toBe("string")
|
expect(typeof weather.temperatureApparent).toBe("number")
|
||||||
expect(typeof weather!.humidity).toBe("number")
|
expect(typeof weather.condition).toBe("string")
|
||||||
expect(typeof weather!.uvIndex).toBe("number")
|
expect(typeof weather.humidity).toBe("number")
|
||||||
expect(typeof weather!.windSpeed).toBe("number")
|
expect(typeof weather.uvIndex).toBe("number")
|
||||||
expect(typeof weather!.daylight).toBe("boolean")
|
expect(typeof weather.windSpeed).toBe("number")
|
||||||
|
expect(typeof weather.daylight).toBe("boolean")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("converts temperature to imperial", async () => {
|
test("converts temperature to imperial", async () => {
|
||||||
@@ -84,12 +87,12 @@ describe("WeatherSource", () => {
|
|||||||
})
|
})
|
||||||
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
|
||||||
|
|
||||||
const result = await source.fetchContext(context)
|
const entries = await source.fetchContext(context)
|
||||||
expect(result).not.toBeNull()
|
expect(entries).not.toBeNull()
|
||||||
const weather = contextValue(result! as Context, WeatherKey)
|
|
||||||
|
|
||||||
|
const [, weather] = entries![0]! as [typeof WeatherKey, Weather]
|
||||||
// Fixture has temperature around 10°C, imperial should be around 50°F
|
// 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", () => {
|
describe("no reactive methods", () => {
|
||||||
test("does not implement onContextUpdate", () => {
|
test("does not implement onContextUpdate", () => {
|
||||||
const source = new WeatherSource({ credentials: mockCredentials })
|
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
|
||||||
expect(source.onContextUpdate).toBeUndefined()
|
expect(source.onContextUpdate).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not implement onItemsUpdate", () => {
|
test("does not implement onItemsUpdate", () => {
|
||||||
const source = new WeatherSource({ credentials: mockCredentials })
|
const source: FeedSource = new WeatherSource({ credentials: mockCredentials })
|
||||||
expect(source.onItemsUpdate).toBeUndefined()
|
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 { LocationKey } from "@aris/source-location"
|
||||||
|
|
||||||
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||||||
@@ -86,7 +86,7 @@ const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
|||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Access weather context in downstream sources
|
* // Access weather context in downstream sources
|
||||||
* const weather = contextValue(context, WeatherKey)
|
* const weather = context.get(WeatherKey)
|
||||||
* if (weather?.condition === "Rain") {
|
* if (weather?.condition === "Rain") {
|
||||||
* // suggest umbrella
|
* // suggest umbrella
|
||||||
* }
|
* }
|
||||||
@@ -119,8 +119,8 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
throw new UnknownActionError(actionId)
|
throw new UnknownActionError(actionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchContext(context: Context): Promise<Partial<Context> | null> {
|
async fetchContext(context: Context): Promise<readonly ContextEntry[] | null> {
|
||||||
const location = contextValue(context, LocationKey)
|
const location = context.get(LocationKey)
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -147,11 +147,11 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
daylight: response.currentWeather.daylight,
|
daylight: response.currentWeather.daylight,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { [WeatherKey]: weather }
|
return [[WeatherKey, weather]]
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
||||||
const location = contextValue(context, LocationKey)
|
const location = context.get(LocationKey)
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user