Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 101 additions & 13 deletions src/query/cache.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,164 @@
/**
* Determines the shape of the Cache instance.
* Minimal synchronous storage contract used by the query caches.
* Values are retained, not cloned; Map implements this contract. Custom
* stores must expose current keys and return undefined for missing entries.
*
* @example
* const cache: Cache<number> = new Map()
* cache.set('count', 1)
* console.log(cache.get('count')) // 1
*/
export interface Cache<T = unknown> {
/**
* Gets an item from the cache.
* Reads the retained entry without fetching or checking expiration.
* Missing keys return undefined; the stored object is not copied.
*
* @example
* const cache: Cache<number> = new Map([['count', 1]])
* console.log(cache.get('count')) // 1
*/
readonly get: (key: string) => T | undefined

/**
* Sets an item to the cache.
* Retains a value under its exact key, replacing any existing entry.
* Query instances rely on this write being visible synchronously.
*
* @example
* const cache: Cache<number> = new Map()
* cache.set('count', 2)
* console.log(cache.get('count')) // 2
*/
readonly set: (key: string, value: T) => void

/**
* Removes a key-value pair from the cache.
* Removes the entry synchronously; deleting a missing key is harmless.
* Direct deletion does not emit query events or cancel a request.
*
* @example
* const cache: Cache<number> = new Map([['count', 1]])
* cache.delete('count')
* console.log(cache.get('count')) // undefined
*/
readonly delete: (key: string) => void

/**
* Returns the current cached keys.
* Iterates the stored keys in the underlying store's iteration order.
* No expiration filtering is performed by this storage interface.
*
* @example
* const cache: Cache<number> = new Map([['count', 1]])
* console.log(Array.from(cache.keys())) // ['count']
*/
readonly keys: () => IterableIterator<string>
}

/**
* Determines how we store items in the items cache.
* Retained result promise and its expiration date in the items cache.
* Expired entries remain readable until overwritten or explicitly forgotten.
*
* @example
* const entry: ItemsCacheItem<number> = {
* item: Promise.resolve(1), expiresAt: new Date(0),
* }
* console.log(await entry.item) // 1
*/
export interface ItemsCacheItem<T = unknown> {
/**
* Stores the cache item.
* Keeps the result promise so valid cache reads preserve promise identity.
* Awaiting a custom cache's rejected promise can reject a snapshot read.
*
* @example
* const entry: ItemsCacheItem<number> = { item: Promise.resolve(1), expiresAt: new Date(0) }
* console.log(await entry.item) // 1
*/
readonly item: Promise<T>

/**
* Determines the expiration date of the item.
* Absolute time after which a query may revalidate this entry.
* The Date is retained, not cloned; do not mutate it after insertion.
*
* @example
* const entry: ItemsCacheItem<number> = { item: Promise.resolve(1), expiresAt: new Date(0) }
* console.log(entry.expiresAt.getTime()) // 0
*/
readonly expiresAt: Date
}

/**
* Determines how we store items in the resolvers cache.
* Active fetch promise and its cancellation/ownership controller.
* Query instances remove the entry when its owning request settles.
*
* @example
* const entry: ResolversCacheItem<number> = {
* item: Promise.resolve(1), controller: new AbortController(),
* }
* console.log(await entry.item) // 1
*/
export interface ResolversCacheItem<T = unknown> {
/**
* The resolvable item.
* Pending request promise shared by deduplicated callers of this key.
* It can reject when fetching fails or cancellation is observed.
*
* @example
* const entry: ResolversCacheItem<number> = {
* item: Promise.resolve(1), controller: new AbortController(),
* }
* console.log(await entry.item) // 1
*/
readonly item: Promise<T>

/**
* The abort controller for the request.
* Signals cancellation and identifies which request owns the resolver.
* A fetcher must observe its signal to cancel underlying work promptly.
*
* @example
* const entry: ResolversCacheItem<number> = {
* item: Promise.resolve(1), controller: new AbortController(),
* }
* entry.controller.abort('navigation')
*/
readonly controller: AbortController
}

/**
* Determines the cache type.
* Selects stored results or active requests for key enumeration.
* The default for query.keys() is the items cache.
*
* @example
* const cache: CacheType = 'resolvers'
* console.log(createQuery().keys(cache)) // []
*/
export type CacheType = 'resolvers' | 'items'

/**
* The caches available on the query.
* Live storage references returned by query.caches(), not a snapshot copy.
* Prefer query methods for writes so ownership and events stay consistent.
*
* @example
* const caches: Caches = createQuery().caches()
* console.log(Array.from(caches.items.keys())) // []
*/
export interface Caches {
/**
* A cache that contains the resolved items alongside
* their expiration time.
* Direct access bypasses event delivery and request ownership rules.
*
* @example
* const query = createQuery()
* query.hydrate('count', 1)
* console.log(await query.caches().items.get('count')?.item) // 1
*/
readonly items: Cache<ItemsCacheItem>

/**
* A cache that contains the resolvers alongside
* their abort controllers.
* Inspect it for diagnostics; use query.abort() to cancel with events.
*
* @example
* const query = createQuery()
* console.log(Array.from(query.caches().resolvers.keys())) // []
*/
readonly resolvers: Cache<ResolversCacheItem>
}
20 changes: 20 additions & 0 deletions src/query/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
/**
* Framework-independent promise cache with keyed events and explicit writes.
* createQuery owns request selection, expiration, cancellation signals, and
* per-key mutation ordering. Supplied objects are retained, not cloned.
* Fetched values live for 2000 ms by default; local writes expire immediately.
* Channels are opt-in and caller-owned. Channel names are not authorization.
*
* Examples in this directory use named exports from this package entry point.
* Fetch failures reject caller promises; stale background failures emit error.
* For framework render lifetimes use Query.resource rather than repeatedly
* forcing fresh imperative queries.
*
* @example
* import { createQuery } from '@studiolambda/query'
* const query = createQuery({ fetcher: async () => ({ name: 'Ada' }) })
* try { console.log(await query.query('profile')) }
* catch (error) { console.error(error) }
*
* @module
*/
export * from './query'
export * from './cache'
export * from './options'
Loading
Loading