From 75ce06d39bbc1ff9daf1280e4120dd21c8f0acc8 Mon Sep 17 00:00:00 2001 From: kenneth Date: Mon, 19 Jan 2026 00:28:03 +0000 Subject: [PATCH] feat(source-location): add LocationSource for push-based location context Implements FeedSource interface. Accepts external location pushes, provides context to downstream sources, does not produce feed items. Supports configurable history size. Co-authored-by: Ona --- bun.lock | 9 ++ packages/aris-source-location/README.md | 112 +++++++++++++ packages/aris-source-location/package.json | 13 ++ packages/aris-source-location/src/index.ts | 6 + .../src/location-source.test.ts | 150 ++++++++++++++++++ .../src/location-source.ts | 86 ++++++++++ 6 files changed, 376 insertions(+) create mode 100644 packages/aris-source-location/README.md create mode 100644 packages/aris-source-location/package.json create mode 100644 packages/aris-source-location/src/index.ts create mode 100644 packages/aris-source-location/src/location-source.test.ts create mode 100644 packages/aris-source-location/src/location-source.ts diff --git a/bun.lock b/bun.lock index 8a45bef..a1600f6 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,13 @@ "arktype": "^2.1.0", }, }, + "packages/aris-source-location": { + "name": "@aris/source-location", + "version": "0.0.0", + "dependencies": { + "@aris/core": "workspace:*", + }, + }, }, "packages": { "@aris/core": ["@aris/core@workspace:packages/aris-core"], @@ -41,6 +48,8 @@ "@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"], + "@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"], + "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], "@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="], diff --git a/packages/aris-source-location/README.md b/packages/aris-source-location/README.md new file mode 100644 index 0000000..6a25fb0 --- /dev/null +++ b/packages/aris-source-location/README.md @@ -0,0 +1,112 @@ +# @aris/source-location + +A FeedSource that provides location context to the ARIS feed graph. + +## Overview + +This source accepts external location pushes and does not query location itself. It provides location context to downstream sources (e.g., weather, transit) but does not produce feed items. + +## Installation + +```bash +bun add @aris/source-location +``` + +## Usage + +```ts +import { LocationSource, LocationKey, type Location } from "@aris/source-location" +import { contextValue } from "@aris/core" + +// Create source with default history size (1) +const locationSource = new LocationSource() + +// Or keep last 10 locations +const locationSource = new LocationSource({ historySize: 10 }) + +// Push location from external provider (GPS, network, etc.) +locationSource.pushLocation({ + lat: 37.7749, + lng: -122.4194, + accuracy: 10, + timestamp: new Date(), +}) + +// Access current location +locationSource.lastLocation // { lat, lng, accuracy, timestamp } | null + +// Access location history (oldest first) +locationSource.locationHistory // readonly Location[] +``` + +### With FeedController + +```ts +import { FeedController } from "@aris/core" +import { LocationSource } from "@aris/source-location" + +const locationSource = new LocationSource() + +const controller = new FeedController({ + sources: [locationSource, weatherSource, transitSource], +}) + +// Push location updates - downstream sources will re-fetch +locationSource.pushLocation({ + lat: 37.7749, + lng: -122.4194, + accuracy: 10, + timestamp: new Date(), +}) +``` + +### Reading Location in Downstream Sources + +```ts +import { contextValue, type FeedSource } from "@aris/core" +import { LocationKey } from "@aris/source-location" + +const weatherSource: FeedSource = { + id: "weather", + dependencies: ["location"], + + async fetchContext(context) { + const location = contextValue(context, LocationKey) + if (!location) return {} + + const weather = await fetchWeather(location.lat, location.lng) + return { [WeatherKey]: weather } + }, +} +``` + +## API + +### `LocationSource` + +| Member | Type | Description | +| ------------------------ | --------------------- | ------------------------------------- | +| `id` | `"location"` | Source identifier | +| `constructor(options?)` | | Create with optional `historySize` | +| `pushLocation(location)` | `void` | Push new location, notifies listeners | +| `lastLocation` | `Location \| null` | Most recent location | +| `locationHistory` | `readonly Location[]` | All retained locations, oldest first | + +### `Location` + +```ts +interface Location { + lat: number + lng: number + accuracy: number // meters + timestamp: Date +} +``` + +### `LocationKey` + +Typed context key for accessing location in downstream sources: + +```ts +const location = contextValue(context, LocationKey) +``` diff --git a/packages/aris-source-location/package.json b/packages/aris-source-location/package.json new file mode 100644 index 0000000..983e38d --- /dev/null +++ b/packages/aris-source-location/package.json @@ -0,0 +1,13 @@ +{ + "name": "@aris/source-location", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test src/" + }, + "dependencies": { + "@aris/core": "workspace:*" + } +} diff --git a/packages/aris-source-location/src/index.ts b/packages/aris-source-location/src/index.ts new file mode 100644 index 0000000..3dce72f --- /dev/null +++ b/packages/aris-source-location/src/index.ts @@ -0,0 +1,6 @@ +export { + LocationSource, + LocationKey, + type Location, + type LocationSourceOptions, +} from "./location-source.ts" diff --git a/packages/aris-source-location/src/location-source.test.ts b/packages/aris-source-location/src/location-source.test.ts new file mode 100644 index 0000000..b32270e --- /dev/null +++ b/packages/aris-source-location/src/location-source.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, mock, test } from "bun:test" + +import { LocationKey, LocationSource, type Location } from "./location-source.ts" + +function createLocation(overrides: Partial = {}): Location { + return { + lat: 37.7749, + lng: -122.4194, + accuracy: 10, + timestamp: new Date(), + ...overrides, + } +} + +describe("LocationSource", () => { + describe("FeedSource interface", () => { + test("has correct id", () => { + const source = new LocationSource() + expect(source.id).toBe("location") + }) + + test("fetchItems always returns empty array", async () => { + const source = new LocationSource() + source.pushLocation(createLocation()) + + const items = await source.fetchItems() + expect(items).toEqual([]) + }) + + test("fetchContext returns empty when no location", async () => { + const source = new LocationSource() + + const context = await source.fetchContext() + expect(context).toEqual({}) + }) + + test("fetchContext returns location when available", async () => { + const source = new LocationSource() + const location = createLocation() + source.pushLocation(location) + + const context = await source.fetchContext() + expect(context).toEqual({ [LocationKey]: location }) + }) + }) + + describe("pushLocation", () => { + test("updates lastLocation", () => { + const source = new LocationSource() + expect(source.lastLocation).toBeNull() + + const location = createLocation() + source.pushLocation(location) + + expect(source.lastLocation).toEqual(location) + }) + + test("notifies listeners", () => { + const source = new LocationSource() + const listener = mock() + + source.onContextUpdate(listener) + + const location = createLocation() + source.pushLocation(location) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ [LocationKey]: location }) + }) + }) + + describe("history", () => { + test("default historySize is 1", () => { + const source = new LocationSource() + + source.pushLocation(createLocation({ lat: 1 })) + source.pushLocation(createLocation({ lat: 2 })) + + expect(source.locationHistory).toHaveLength(1) + expect(source.lastLocation?.lat).toBe(2) + }) + + test("respects configured historySize", () => { + const source = new LocationSource({ historySize: 3 }) + + const loc1 = createLocation({ lat: 1 }) + const loc2 = createLocation({ lat: 2 }) + const loc3 = createLocation({ lat: 3 }) + + source.pushLocation(loc1) + source.pushLocation(loc2) + source.pushLocation(loc3) + + expect(source.locationHistory).toEqual([loc1, loc2, loc3]) + }) + + test("evicts oldest when exceeding historySize", () => { + const source = new LocationSource({ historySize: 2 }) + + const loc1 = createLocation({ lat: 1 }) + const loc2 = createLocation({ lat: 2 }) + const loc3 = createLocation({ lat: 3 }) + + source.pushLocation(loc1) + source.pushLocation(loc2) + source.pushLocation(loc3) + + expect(source.locationHistory).toEqual([loc2, loc3]) + }) + + test("locationHistory is readonly", () => { + const source = new LocationSource({ historySize: 3 }) + source.pushLocation(createLocation()) + + const history = source.locationHistory + expect(Array.isArray(history)).toBe(true) + }) + }) + + describe("onContextUpdate", () => { + test("returns cleanup function", () => { + const source = new LocationSource() + const listener = mock() + + const cleanup = source.onContextUpdate(listener) + + source.pushLocation(createLocation({ lat: 1 })) + expect(listener).toHaveBeenCalledTimes(1) + + cleanup() + + source.pushLocation(createLocation({ lat: 2 })) + expect(listener).toHaveBeenCalledTimes(1) + }) + + test("supports multiple listeners", () => { + const source = new LocationSource() + const listener1 = mock() + const listener2 = mock() + + source.onContextUpdate(listener1) + source.onContextUpdate(listener2) + + source.pushLocation(createLocation()) + + expect(listener1).toHaveBeenCalledTimes(1) + expect(listener2).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/aris-source-location/src/location-source.ts b/packages/aris-source-location/src/location-source.ts new file mode 100644 index 0000000..852046b --- /dev/null +++ b/packages/aris-source-location/src/location-source.ts @@ -0,0 +1,86 @@ +import type { Context, FeedSource } from "@aris/core" + +import { contextKey, type ContextKey } from "@aris/core" + +/** + * Geographic coordinates with accuracy and timestamp. + */ +export interface Location { + lat: number + lng: number + /** Accuracy in meters */ + accuracy: number + timestamp: Date +} + +export interface LocationSourceOptions { + /** Number of locations to retain in history. Defaults to 1. */ + historySize?: number +} + +export const LocationKey: ContextKey = contextKey("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 = "location" + + private readonly historySize: number + private locations: Location[] = [] + private listeners = new Set<(update: Partial) => void>() + + constructor(options: LocationSourceOptions = {}) { + this.historySize = options.historySize ?? 1 + } + + /** + * 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() + } + this.listeners.forEach((listener) => { + listener({ [LocationKey]: location }) + }) + } + + /** + * 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: (update: Partial) => void): () => void { + this.listeners.add(callback) + return () => { + this.listeners.delete(callback) + } + } + + async fetchContext(): Promise> { + if (this.lastLocation) { + return { [LocationKey]: this.lastLocation } + } + return {} + } + + async fetchItems(): Promise<[]> { + return [] + } +}