mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 05:01:17 +00:00
initial commit
This commit is contained in:
10
packages/aris-core/context.ts
Normal file
10
packages/aris-core/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Location {
|
||||
lat: number
|
||||
lng: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
time: Date
|
||||
location?: Location
|
||||
}
|
||||
7
packages/aris-core/data-source.ts
Normal file
7
packages/aris-core/data-source.ts
Normal 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[]>
|
||||
}
|
||||
10
packages/aris-core/feed.ts
Normal file
10
packages/aris-core/feed.ts
Normal 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
|
||||
}
|
||||
5
packages/aris-core/index.ts
Normal file
5
packages/aris-core/index.ts
Normal 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"
|
||||
10
packages/aris-core/package.json
Normal file
10
packages/aris-core/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@aris/core",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"types": "index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test ."
|
||||
}
|
||||
}
|
||||
240
packages/aris-core/reconciler.test.ts
Normal file
240
packages/aris-core/reconciler.test.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
88
packages/aris-core/reconciler.ts
Normal file
88
packages/aris-core/reconciler.ts
Normal 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),
|
||||
),
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user