initial commit

This commit is contained in:
2026-01-16 00:56:55 +00:00
commit 90fd137b77
21 changed files with 1308 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
export interface Location {
lat: number
lng: number
accuracy: number
}
export interface Context {
time: Date
location?: Location
}

View File

@@ -0,0 +1,7 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
export interface DataSource<TItem extends FeedItem = FeedItem, TConfig = unknown> {
readonly type: TItem["type"]
query(context: Context, config: TConfig): Promise<TItem[]>
}

View File

@@ -0,0 +1,10 @@
export interface FeedItem<
TType extends string = string,
TData extends Record<string, unknown> = Record<string, unknown>,
> {
id: string
type: TType
priority: number
timestamp: Date
data: TData
}

View File

@@ -0,0 +1,5 @@
export type { Context, Location } from "./context"
export type { FeedItem } from "./feed"
export type { DataSource } from "./data-source"
export type { ReconcilerConfig, ReconcileResult, SourceError } from "./reconciler"
export { Reconciler } from "./reconciler"

View File

@@ -0,0 +1,10 @@
{
"name": "@aris/core",
"version": "0.0.0",
"type": "module",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"test": "bun test ."
}
}

View File

@@ -0,0 +1,240 @@
import { describe, expect, test } from "bun:test"
import type { Context } from "./context"
import type { DataSource } from "./data-source"
import type { FeedItem } from "./feed"
import { Reconciler } from "./reconciler"
type WeatherData = { temp: number }
type WeatherItem = FeedItem<"weather", WeatherData>
type CalendarData = { title: string }
type CalendarItem = FeedItem<"calendar", CalendarData>
const createMockContext = (): Context => ({
time: new Date("2026-01-15T12:00:00Z"),
})
const createWeatherSource = (items: WeatherItem[], delay = 0): DataSource<WeatherItem> => ({
type: "weather",
async query() {
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
return items
},
})
const createCalendarSource = (items: CalendarItem[]): DataSource<CalendarItem> => ({
type: "calendar",
async query() {
return items
},
})
const createFailingSource = (type: string, error: Error): DataSource<FeedItem> => ({
type,
async query() {
throw error
},
})
describe("Reconciler", () => {
test("returns empty result when no sources registered", async () => {
const reconciler = new Reconciler()
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
expect(result.errors).toEqual([])
})
test("collects items from single source", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler().register(createWeatherSource(items))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual(items)
expect(result.errors).toEqual([])
})
test("collects items from multiple sources", async () => {
const weatherItems: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const calendarItems: CalendarItem[] = [
{
id: "calendar-1",
type: "calendar",
priority: 0.8,
timestamp: new Date(),
data: { title: "Meeting" },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(weatherItems))
.register(createCalendarSource(calendarItems))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toHaveLength(2)
expect(result.errors).toEqual([])
})
test("sorts items by priority descending", async () => {
const weatherItems: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.2,
timestamp: new Date(),
data: { temp: 20 },
},
]
const calendarItems: CalendarItem[] = [
{
id: "calendar-1",
type: "calendar",
priority: 0.9,
timestamp: new Date(),
data: { title: "Meeting" },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(weatherItems))
.register(createCalendarSource(calendarItems))
const result = await reconciler.reconcile(createMockContext())
expect(result.items[0]?.id).toBe("calendar-1")
expect(result.items[1]?.id).toBe("weather-1")
})
test("captures errors from failing sources", async () => {
const error = new Error("Source failed")
const reconciler = new Reconciler().register(createFailingSource("failing", error))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
expect(result.errors).toHaveLength(1)
expect(result.errors[0]?.sourceType).toBe("failing")
expect(result.errors[0]?.error.message).toBe("Source failed")
})
test("returns partial results when some sources fail", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(items))
.register(createFailingSource("failing", new Error("Failed")))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toHaveLength(1)
expect(result.errors).toHaveLength(1)
})
test("times out slow sources", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler({ timeout: 50 }).register(createWeatherSource(items, 100))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
expect(result.errors).toHaveLength(1)
expect(result.errors[0]?.sourceType).toBe("weather")
expect(result.errors[0]?.error.message).toContain("timed out")
})
test("unregister removes source", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler().register(createWeatherSource(items)).unregister("weather")
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
})
test("infers discriminated union type from chained registers", async () => {
const weatherItems: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const calendarItems: CalendarItem[] = [
{
id: "calendar-1",
type: "calendar",
priority: 0.8,
timestamp: new Date(),
data: { title: "Meeting" },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(weatherItems))
.register(createCalendarSource(calendarItems))
const { items } = await reconciler.reconcile(createMockContext())
// Type narrowing should work
for (const item of items) {
if (item.type === "weather") {
expect(typeof item.data.temp).toBe("number")
} else if (item.type === "calendar") {
expect(typeof item.data.title).toBe("string")
}
}
})
})

View File

@@ -0,0 +1,88 @@
import type { Context } from "./context"
import type { DataSource } from "./data-source"
import type { FeedItem } from "./feed"
export interface ReconcilerConfig {
timeout?: number
}
export interface SourceError {
sourceType: string
error: Error
}
export interface ReconcileResult<TItem extends FeedItem = FeedItem> {
items: TItem[]
errors: SourceError[]
}
interface RegisteredSource {
source: DataSource<FeedItem, unknown>
config: unknown
}
const DEFAULT_TIMEOUT = 5000
export class Reconciler<TItems extends FeedItem = never> {
private sources = new Map<string, RegisteredSource>()
private timeout: number
constructor(config?: ReconcilerConfig) {
this.timeout = config?.timeout ?? DEFAULT_TIMEOUT
}
register<TItem extends FeedItem, TConfig>(
source: DataSource<TItem, TConfig>,
config?: TConfig,
): Reconciler<TItems | TItem> {
this.sources.set(source.type, {
source: source as DataSource<FeedItem, unknown>,
config,
})
return this as Reconciler<TItems | TItem>
}
unregister<T extends TItems["type"]>(sourceType: T): Reconciler<Exclude<TItems, { type: T }>> {
this.sources.delete(sourceType)
return this as unknown as Reconciler<Exclude<TItems, { type: T }>>
}
async reconcile(context: Context): Promise<ReconcileResult<TItems>> {
const entries = Array.from(this.sources.values())
const results = await Promise.allSettled(
entries.map(({ source, config }) =>
withTimeout(source.query(context, config), this.timeout, source.type),
),
)
const items: FeedItem[] = []
const errors: SourceError[] = []
results.forEach((result, i) => {
const sourceType = entries[i]!.source.type
if (result.status === "fulfilled") {
items.push(...result.value)
} else {
errors.push({
sourceType,
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
})
}
})
items.sort((a, b) => b.priority - a.priority)
return { items, errors } as ReconcileResult<TItems>
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, sourceType: string): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Source "${sourceType}" timed out after ${ms}ms`)), ms),
),
])
}