mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 05:01:17 +00:00
Merge pull request #12 from kennethnym/feat/source-location
feat(source-location): add LocationSource for push-based location context
This commit is contained in:
9
bun.lock
9
bun.lock
@@ -33,6 +33,13 @@
|
|||||||
"arktype": "^2.1.0",
|
"arktype": "^2.1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/aris-source-location": {
|
||||||
|
"name": "@aris/source-location",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@aris/core": ["@aris/core@workspace:packages/aris-core"],
|
"@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/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/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=="],
|
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
|
||||||
|
|||||||
112
packages/aris-source-location/README.md
Normal file
112
packages/aris-source-location/README.md
Normal file
@@ -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)
|
||||||
|
```
|
||||||
13
packages/aris-source-location/package.json
Normal file
13
packages/aris-source-location/package.json
Normal file
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/aris-source-location/src/index.ts
Normal file
6
packages/aris-source-location/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
LocationSource,
|
||||||
|
LocationKey,
|
||||||
|
type Location,
|
||||||
|
type LocationSourceOptions,
|
||||||
|
} from "./location-source.ts"
|
||||||
150
packages/aris-source-location/src/location-source.test.ts
Normal file
150
packages/aris-source-location/src/location-source.test.ts
Normal file
@@ -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> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
86
packages/aris-source-location/src/location-source.ts
Normal file
86
packages/aris-source-location/src/location-source.ts
Normal file
@@ -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<Location> = 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<Context>) => 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<Context>) => void): () => void {
|
||||||
|
this.listeners.add(callback)
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchContext(): Promise<Partial<Context>> {
|
||||||
|
if (this.lastLocation) {
|
||||||
|
return { [LocationKey]: this.lastLocation }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchItems(): Promise<[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user