Files
aris/packages/aris-source-location/src/location-source.ts
Kenneth 96e22e227c feat: replace flat context with tuple-keyed store (#50)
Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.

Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().

Co-authored-by: Ona <no-reply@ona.com>
2026-03-01 22:52:41 +00:00

100 lines
2.6 KiB
TypeScript

import type { ActionDefinition, ContextEntry, FeedSource } from "@aris/core"
import { Context, UnknownActionError, contextKey, type ContextKey } from "@aris/core"
import { type } from "arktype"
import { Location, type LocationSourceOptions } from "./types.ts"
export const LocationKey: ContextKey<Location> = contextKey("aris.location", "location")
/**
* A FeedSource that provides location context.
*
* This source accepts external location pushes and does not query location itself.
* Use `pushLocation` to update the location from an external provider (e.g., GPS, network).
*
* Does not produce feed items - always returns empty array from `fetchItems`.
*/
export class LocationSource implements FeedSource {
readonly id = "aris.location"
private readonly historySize: number
private locations: Location[] = []
private listeners = new Set<(entries: readonly ContextEntry[]) => void>()
constructor(options: LocationSourceOptions = {}) {
this.historySize = options.historySize ?? 1
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {
"update-location": {
id: "update-location",
description: "Push a new location update",
input: Location,
},
}
}
async executeAction(actionId: string, params: unknown): Promise<void> {
switch (actionId) {
case "update-location": {
const result = Location(params)
if (result instanceof type.errors) {
throw new Error(result.summary)
}
this.pushLocation(result)
return
}
default:
throw new UnknownActionError(actionId)
}
}
/**
* Push a new location update. Notifies all context listeners.
*/
pushLocation(location: Location): void {
this.locations.push(location)
if (this.locations.length > this.historySize) {
this.locations.shift()
}
const entries: readonly ContextEntry[] = [[LocationKey, location]]
this.listeners.forEach((listener) => {
listener(entries)
})
}
/**
* Most recent location, or null if none pushed.
*/
get lastLocation(): Location | null {
return this.locations[this.locations.length - 1] ?? null
}
/**
* Location history, oldest first. Length limited by `historySize`.
*/
get locationHistory(): readonly Location[] {
return this.locations
}
onContextUpdate(callback: (entries: readonly ContextEntry[]) => void): () => void {
this.listeners.add(callback)
return () => {
this.listeners.delete(callback)
}
}
async fetchContext(): Promise<readonly ContextEntry[] | null> {
if (this.lastLocation) {
return [[LocationKey, this.lastLocation]]
}
return null
}
async fetchItems(): Promise<[]> {
return []
}
}