mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
1 Commits
230116d9f7
...
feat/tuple
| Author | SHA1 | Date | |
|---|---|---|---|
|
57b38cafaf
|
@@ -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]: {
|
[
|
||||||
|
CalDavCalendarKey,
|
||||||
|
{
|
||||||
inProgress: [],
|
inProgress: [],
|
||||||
nextEvent: null,
|
nextEvent: null,
|
||||||
hasTodayEvents: false,
|
hasTodayEvents: false,
|
||||||
todayEventCount: 0,
|
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