mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
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>
This commit is contained in:
@@ -1,46 +1,131 @@
|
||||
/**
|
||||
* Branded type for type-safe context keys.
|
||||
* Tuple-keyed context system inspired by React Query's query keys.
|
||||
*
|
||||
* Each package defines its own keys with associated value types:
|
||||
* ```ts
|
||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||
* ```
|
||||
* Context keys are arrays that form a hierarchy. Sources write to specific
|
||||
* keys (e.g., ["aris.google-calendar", "nextEvent", { account: "work" }])
|
||||
* and consumers can query by exact match or prefix match to get all values
|
||||
* of a given type across source instances.
|
||||
*/
|
||||
export type ContextKey<T> = string & { __contextValue?: T }
|
||||
|
||||
/**
|
||||
* Creates a typed context key.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface Location { lat: number; lng: number; accuracy: number }
|
||||
* const LocationKey: ContextKey<Location> = contextKey("location")
|
||||
* ```
|
||||
*/
|
||||
export function contextKey<T>(key: string): ContextKey<T> {
|
||||
return key as ContextKey<T>
|
||||
// -- Key types --
|
||||
|
||||
/** A single segment of a context key: string, number, or a record of primitives. */
|
||||
export type ContextKeyPart = string | number | Record<string, unknown>
|
||||
|
||||
/** A context key is a readonly tuple of parts, branded with the value type. */
|
||||
export type ContextKey<T> = readonly ContextKeyPart[] & { __contextValue?: T }
|
||||
|
||||
/** Creates a typed context key. */
|
||||
export function contextKey<T>(...parts: ContextKeyPart[]): ContextKey<T> {
|
||||
return parts as ContextKey<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe accessor for context values.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const location = contextValue(context, LocationKey)
|
||||
* if (location) {
|
||||
* console.log(location.lat, location.lng)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
|
||||
return context[key] as T | undefined
|
||||
}
|
||||
// -- Serialization --
|
||||
|
||||
/**
|
||||
* Arbitrary key-value bag representing the current state.
|
||||
* Always includes `time`. Other keys are added by context providers.
|
||||
* Deterministic serialization of a context key for use as a Map key.
|
||||
* Object parts have their keys sorted for stable comparison.
|
||||
*/
|
||||
export interface Context {
|
||||
export function serializeKey(key: readonly ContextKeyPart[]): string {
|
||||
return JSON.stringify(key, (_key, value) => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
const sorted: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(value).sort()) {
|
||||
sorted[k] = value[k]
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
// -- Key matching --
|
||||
|
||||
/** Returns true if `key` starts with all parts of `prefix`. */
|
||||
function keyStartsWith(key: readonly ContextKeyPart[], prefix: readonly ContextKeyPart[]): boolean {
|
||||
if (key.length < prefix.length) return false
|
||||
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (!partsEqual(key[i]!, prefix[i]!)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Recursive structural equality, matching React Query's partialMatchKey approach. */
|
||||
function partsEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a !== typeof b) return false
|
||||
if (a && b && typeof a === "object" && typeof b === "object") {
|
||||
const aKeys = Object.keys(a)
|
||||
const bKeys = Object.keys(b)
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
return aKeys.every((key) =>
|
||||
partsEqual(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key],
|
||||
),
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// -- Context store --
|
||||
|
||||
/** A single context entry: a key-value pair. */
|
||||
export type ContextEntry<T = unknown> = readonly [ContextKey<T>, T]
|
||||
|
||||
/**
|
||||
* Mutable context store with tuple keys.
|
||||
*
|
||||
* Supports exact-match lookups and prefix-match queries.
|
||||
* Sources write context in topological order during refresh.
|
||||
*/
|
||||
export class Context {
|
||||
time: Date
|
||||
[key: string]: unknown
|
||||
private readonly store: Map<string, { key: readonly ContextKeyPart[]; value: unknown }>
|
||||
|
||||
constructor(time: Date = new Date()) {
|
||||
this.time = time
|
||||
this.store = new Map()
|
||||
}
|
||||
|
||||
/** Merges entries into this context. */
|
||||
set(entries: readonly ContextEntry[]): void {
|
||||
for (const [key, value] of entries) {
|
||||
this.store.set(serializeKey(key), { key, value })
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact-match lookup. Returns the value for the given key, or undefined. */
|
||||
get<T>(key: ContextKey<T>): T | undefined {
|
||||
const entry = this.store.get(serializeKey(key))
|
||||
return entry?.value as T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix-match query. Returns all entries whose key starts with the given prefix.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Get all "nextEvent" values across calendar source instances
|
||||
* const events = context.find(contextKey("nextEvent"))
|
||||
* ```
|
||||
*/
|
||||
find<T>(prefix: ContextKey<T>): Array<{ key: readonly ContextKeyPart[]; value: T }> {
|
||||
const results: Array<{ key: readonly ContextKeyPart[]; value: T }> = []
|
||||
|
||||
for (const entry of this.store.values()) {
|
||||
if (keyStartsWith(entry.key, prefix)) {
|
||||
results.push({ key: entry.key, value: entry.value as T })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/** Returns the number of entries (excluding time). */
|
||||
get size(): number {
|
||||
return this.store.size
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user