From 68f4919487ee6eeee55156f557ccd06f38e71f0c Mon Sep 17 00:00:00 2001 From: nenharper Date: Mon, 15 Sep 2025 10:21:19 -0500 Subject: [PATCH 1/3] Revamp the caching page and add a reference page --- .../developers/applications/caching.md | 301 +++--------------- .../version-4.6/reference/caching.md | 272 ++++++++++++++++ 2 files changed, 319 insertions(+), 254 deletions(-) create mode 100644 versioned_docs/version-4.6/reference/caching.md diff --git a/versioned_docs/version-4.6/developers/applications/caching.md b/versioned_docs/version-4.6/developers/applications/caching.md index e655a32c..3b2525ae 100644 --- a/versioned_docs/version-4.6/developers/applications/caching.md +++ b/versioned_docs/version-4.6/developers/applications/caching.md @@ -1,292 +1,85 @@ --- title: Caching --- - # Caching -Harper has integrated support for caching data from external sources. With built-in caching capabilities and distributed high-performance low-latency responsiveness, Harper makes an ideal data caching server. Harper can store cached data in standard tables, as queryable structured data, so data can easily be consumed in one format (for example JSON or CSV) and provided to end users in different formats with different selected properties (for example MessagePack, with a subset of selected properties), or even with customized querying capabilities. Harper also manages and provides timestamps/tags for proper caching control, facilitating further downstreaming caching. With these combined capabilities, Harper is an extremely fast, interoperable, flexible, and customizable caching server. +In the [quickstart guide](../../getting-started/first-harper-app.md), you built a working Dog API in just a few minutes. That API is now ready to store and query data. But what happens when your application also needs to pull in data from other systems—say, a third-party service that provides dog breed information? -## Configuring Caching +If you hit that external API every time a user makes a request, you’ll pay the cost in speed, reliability, and maybe even money. That’s where Harper’s built-in caching comes in. Harper lets you cache external data in the same tables and APIs you’re already using, so your app feels fast, reliable, and cost-efficient without you writing glue code. -To set up caching, first you will need to define a table that you will use as your cache (to store the cached data). You can review the [introduction to building applications](./) for more information on setting up the application (and the [defining schemas documentation](./defining-schemas)), but once you have defined an application folder with a schema, you can add a table for caching to your `schema.graphql`: +## Step 1: Add a Cache Table +Caching in Harper works just like creating any other table. Open up your `schema.graphql` and add a cache table alongside your `Dog` table: ```graphql -type MyCache @table(expiration: 3600) @export { - id: ID @primaryKey -} -``` - -You may also note that we can define a time-to-live (TTL) expiration on the table, indicating when table records/entries should expire and be evicted from this table. This is generally necessary for "passive" caches where there is no active notification of when entries expire. However, this is not needed if you provide a means of notifying when data is invalidated and changed. The units for expiration, and other duration-based properties, are in seconds. - -While you can provide a single expiration time, there are actually several expiration timings that are potentially relevant, and can be independently configured. These settings are available as directive properties on the table configuration (like `expiration` above): stale expiration: The point when a request for a record should trigger a request to origin (but might possibly return the current stale record depending on policy) must-revalidate expiration: The point when a request for a record must make a request to origin first and return the latest value from origin. eviction expiration: The point when a record is actually removed from the caching table. - -You can provide a single expiration and it defines the behavior for all three. You can also provide three settings for expiration, through table directives: - -- `expiration` - The amount of time until a record goes stale. -- `eviction` - The amount of time after expiration before a record can be evicted (defaults to zero). -- `scanInterval` - The interval for scanning for expired records (defaults to one quarter of the total of expiration and eviction). - -## Define External Data Source - -Next, you need to define the source for your cache. External data sources could be HTTP APIs, other databases, microservices, or any other source of data. This can be defined as a resource class in your application's `resources.js` module. You can extend the `Resource` class (which is available as a global variable in the Harper environment) as your base class. The first method to implement is a `get()` method to define how to retrieve the source data. For example, if we were caching an external HTTP API, we might define it as such: - -```javascript -class ThirdPartyAPI extends Resource { - async get() { - return (await fetch(`https://some-api.com/${this.getId()}`)).json(); - } -} -``` - -Next, we define this external data resource as the "source" for the caching table we defined above: - -```javascript -const { MyCache } = tables; -MyCache.sourcedFrom(ThirdPartyAPI); -``` - -Now we have a fully configured and connected caching table. If you access data from `MyCache` (for example, through the REST API, like `/MyCache/some-id`), Harper will check to see if the requested entry is in the table and return it if it is available (and hasn't expired). If there is no entry, or it has expired (it is older than one hour in this case), it will go to the source, calling the `get()` method, which will then retrieve the requested entry. Once the entry is retrieved, it will be saved/cached in the caching table (for one hour based on our expiration time). - -```mermaid -flowchart TD - Client1(Client 1)-->Cache(Caching Table) - Client2(Client 2)-->Cache - Cache-->Resource(Data Source Connector) - Resource-->API(Remote Data Source API) -``` - -Harper handles waiting for an existing cache resolution to finish and uses its result. This prevents a "cache stampede" when entries expire, ensuring that multiple requests to a cache entry will all wait on a single request to the data source. - -Cache tables with an expiration are periodically pruned for expired entries. Because this is done periodically, there is usually some amount of time between when a record has expired and when the record is actually evicted (the cached data is removed). But when a record is checked for availability, the expiration time is used to determine if the record is fresh (and the cache entry can be used). - -### Eviction with Indexing - -Eviction is the removal of a locally cached copy of data, but it does not imply the deletion of the actual data from the canonical or origin data source. Because evicted records still exist (just not in the local cache), if a caching table uses expiration (and eviction), and has indexing on certain attributes, the data is not removed from the indexes. The indexes that reference the evicted record are preserved, along with the attribute data necessary to maintain these indexes. Therefore eviction means the removal of non-indexed data (in this case evictions are stored as "partial" records). Eviction only removes the data that can be safely removed from a cache without affecting the integrity or behavior of the indexes. If a search query is performed that matches this evicted record, the record will be requested on-demand to fulfill the search query. - -### Specifying a Timestamp - -In the example above, we simply retrieved data to fulfill a cache request. We may want to supply the timestamp of the record we are fulfilling as well. This can be set on the context for the request: - -```javascript -class ThirdPartyAPI extends Resource { - async get() { - let response = await fetch(`https://some-api.com/${this.getId()}`); - this.getContext().lastModified = response.headers.get('Last-Modified'); - return response.json(); - } -} -``` - -#### Specifying an Expiration - -In addition, we can also specify when a cached record "expires". When a cached record expires, this means that a request for that record will trigger a request to the data source again. This does not necessarily mean that the cached record has been evicted (removed), although expired records will be periodically evicted. If the cached record still exists, the data source can revalidate it and return it. For example: - -```javascript -class ThirdPartyAPI extends Resource { - async get() { - const context = this.getContext(); - let headers = new Headers(); - if (context.replacingVersion) // this is the existing cached record - headers.set('If-Modified-Since', new Date(context.replacingVersion).toUTCString()); - let response = await fetch(`https://some-api.com/${this.getId()}`, { headers }); - let cacheInfo = response.headers.get('Cache-Control'); - let maxAge = cacheInfo?.match(/max-age=(\d)/)?.[1]; - if (maxAge) // we can set a specific expiration time by setting context.expiresAt - context.expiresAt = Date.now() + maxAge * 1000; // convert from seconds to milliseconds and add to current time - // we can just revalidate and return the record if the origin has confirmed that it has the same version: - if (response.status === 304) return context.replacingRecord; - ... -``` - -## Active Caching and Invalidation - -The cache we have created above is a "passive" cache; it only pulls data from the data source as needed, and has no knowledge of if and when data from the data source has actually changed, so it must rely on timer-based expiration to periodically retrieve possibly updated data. This means that it is possible that the cache may have stale data for a while (if the underlying data has changed, but the cached data hasn't expired), and the cache may have to refresh more than necessary if the data source data hasn't changed. Consequently it can be significantly more effective to implement an "active" cache, in which the data source is monitored and notifies the cache when any data changes. This ensures that when data changes, the cache can immediately load the updated data, and unchanged data can remain cached much longer (or indefinitely). - -### Invalidate - -One way to provide more active caching is to specifically invalidate individual records. Invalidation is useful when you know the source data has changed, and the cache needs to re-retrieve data from the source the next time that record is accessed. This can be done by executing the `invalidate()` method on a resource. For example, you could extend a table (in your resources.js) and provide a custom POST handler that does invalidation: - -```javascript -const { MyTable } = tables; -export class MyTableEndpoint extends MyTable { - async post(data) { - if (data.invalidate) - // use this flag as a marker - this.invalidate(); - } +type BreedCache @table(expiration: 3600) @export { + id: ID @primaryKey } ``` +Here, `expiration: 3600` means cached entries expire after an hour. Harper will automatically evict old data and refresh it when needed. By exporting this table, you instantly get a REST API for it at `http://localhost:9926/BreedCache/`. -(Note that if you are now exporting this endpoint through resources.js, you don't necessarily need to directly export the table separately in your schema.graphql). - -### Subscriptions - -We can provide more control of an active cache with subscriptions. If there is a way to receive notifications from the external data source of data changes, we can implement this data source as an "active" data source for our cache by implementing a `subscribe` method. A `subscribe` method should return an asynchronous iterable that iterates and returns events indicating the updates. One straightforward way of creating an asynchronous iterable is by defining the `subscribe` method as an asynchronous generator. If we had an endpoint that we could poll for changes every second, we could implement this like: - -```javascript -class ThirdPartyAPI extends Resource { - async *subscribe() { - setInterval(() => { // every second retrieve more data - // get the next data change event from the source - let update = (await fetch(`https://some-api.com/latest-update`)).json(); - const event = { // define the change event (which will update the cache) - type: 'put', // this would indicate that the event includes the new data value - id: // the primary key of the record that updated - value: // the new value of the record that updated - timestamp: // the timestamp of when the data change occurred - }; - yield event; // this returns this event, notifying the cache of the change - }, 1000); - } - async get() { -... -``` - -Notification events should always include an `id` property to indicate the primary key of the updated record. The event should have a `value` property for `put` and `message` event types. The `timestamp` is optional and can be used to indicate the exact timestamp of the change. The following event `type`s are supported: - -- `put` - This indicates that the record has been updated and provides the new value of the record. -- `invalidate` - Alternately, you can notify with an event type of `invalidate` to indicate that the data has changed, but without the overhead of actually sending the data (the `value` property is not needed), so the data only needs to be sent if and when the data is requested through the cache. An `invalidate` will evict the entry and update the timestamp to indicate that there is new data that should be requested (if needed). -- `delete` - This indicates that the record has been deleted. -- `message` - This indicates a message is being passed through the record. The record value has not changed, but this is used for [publish/subscribe messaging](../real-time). -- `transaction` - This indicates that there are multiple writes that should be treated as a single atomic transaction. These writes should be included as an array of data notification events in the `writes` property. - -And the following properties can be defined on event objects: - -- `type`: The event type as described above. -- `id`: The primary key of the record that updated -- `value`: The new value of the record that updated (for put and message) -- `writes`: An array of event properties that are part of a transaction (used in conjunction with the transaction event type). -- `table`: The name of the table with the record that was updated. This can be used with events within a transaction to specify events across multiple tables. -- `timestamp`: The timestamp of when the data change occurred - -With an active external data source with a `subscribe` method, the data source will proactively notify the cache, ensuring a fresh and efficient active cache. Note that with an active data source, we still use the `sourcedFrom` method to register the source for a caching table, and the table will automatically detect and call the subscribe method on the data source. - -By default, Harper will only run the subscribe method on one thread. Harper is multi-threaded and normally runs many concurrent worker threads, but typically running a subscription on multiple threads can introduce overlap in notifications and race conditions and running on a subscription on a single thread is preferable. However, if you want to enable subscribe on multiple threads, you can define a `static subscribeOnThisThread` method to specify if the subscription should run on the current thread: - -```javascript -class ThirdPartyAPI extends Resource { - static subscribeOnThisThread(threadIndex) { - return threadIndex < 2; // run on two threads (the first two threads) - } - async *subscribe() { - .... -``` +## Step 2: Connect to an External Source +Now let’s say you want to enrich your Dog records with breed details from an external API. Instead of hitting that API directly every time, we’ll connect it to the `BreedCache` table. -An alternative to using asynchronous generators is to use a subscription stream and send events to it. A default subscription stream (that doesn't generate its own events) is available from the Resource's default subscribe method: +Open `resources.js` and define a resource: ```javascript -class ThirdPartyAPI extends Resource { - subscribe() { - const subscription = super.subscribe(); - setupListeningToRemoteService().on('update', (event) => { - subscription.send(event); - }); - return subscription; - } +class BreedAPI extends Resource { + async get() { + return (await fetch(`https://dog-api.com/${this.getId()}`)).json(); + } } -``` - -## Downstream Caching - -It is highly recommended that you utilize the [REST interface](../rest) for accessing caching tables, as it facilitates downstreaming caching for clients. Timestamps are recorded with all cached entries. Timestamps are then used for incoming [REST requests to specify the `ETag` in the response](../rest#cachingconditional-requests). Clients can cache data themselves and send requests using the `If-None-Match` header to conditionally get a 304 and preserve their cached data based on the timestamp/`ETag` of the entries that are cached in Harper. Caching tables also have [subscription capabilities](./caching#subscribing-to-caching-tables), which means that downstream caches can be fully "layered" on top of Harper, both as passive or active caches. - -## Write-Through Caching - -The cache we have defined so far only has data flowing from the data source to the cache. However, you may wish to support write methods, so that writes to the cache table can flow through to underlying canonical data source, as well as populate the cache. This can be accomplished by implementing the standard write methods, like `put` and `delete`. If you were using an API with standard RESTful methods, you can pass writes through to the data source like this: - -```javascript -class ThirdPartyAPI extends Resource { - async put(data) { - await fetch(`https://some-api.com/${this.getId()}`, { - method: 'PUT', - body: JSON.stringify(data) - }); - } - async delete() { - await fetch(`https://some-api.com/${this.getId()}`, { - method: 'DELETE', - }); - } - ... -``` -When doing an insert or update to the MyCache table, the data will be sent to the underlying data source through the `put` method and the new record value will be stored in the cache as well. - -### Loading from Source in Methods - -When you are using a caching table, it is important to remember that any resource methods besides `get()`, will not automatically load data from the source. If you have defined a `put()`, `post()`, or `delete()` method and you need the source data, you can ensure it is loaded by calling the `ensureLoaded()` method. For example, if you want to modify the existing record from the source, adding a property to it: - -```javascript -class MyCache extends tables.MyCache { - async post(data) { - // if the data is not cached locally, retrieves from source: - await this.ensuredLoaded(); - // now we can be sure that the data is loaded, and can access properties - this.quantity = this.quantity - data.purchases; - } -} +const { BreedCache } = tables; +BreedCache.sourcedFrom(BreedAPI); ``` -### Subscribing to Caching Tables +That’s it. When your app calls /BreedCache/husky, Harper will: -You can subscribe to a caching table just like any other table. The one difference is that normal tables do not usually have `invalidate` events, but an active caching table may have `invalidate` events. Again, this event type gives listeners an opportunity to choose whether or not to actually retrieve the value that changed. +- Check if “husky” is already in the cache. +- If not (or if it’s expired), fetch it from https://dog-api.com/husky. +- Store it in BreedCache so the next request is instant. -### Passive-Active Updates +Harper even prevents “cache stampedes”: if multiple users request the same breed at the same time, only one fetch goes out to the source. -With our passive update examples, we have provided a data source handler with a `get()` method that returns the specific requested record as the response. However, we can also actively update other records in our response handler (if our data source provides data that should be propagated to other related records). This can be done transactionally, to ensure that all updates occur atomically. The context that is provided to the data source holds the transaction information, so we can simply pass the context to any update/write methods that we call. For example, let's say we are loading a blog post, which also includes comment records: +## Step 3: Use the Cache in Your API +Now you can combine your Dogs with cached breed info. For example, imagine you added a “breed” attribute to your Dog table earlier: -```javascript -const { Post, Comment } = tables; -class BlogSource extends Resource { - get() { - const post = await (await fetch(`https://my-blog-server/${this.getId()}`).json()); - for (let comment of post.comments) { - await Comment.put(comment, this); // save this comment as part of our current context and transaction - } - return post; - } +```graphql +type Dog @table @export { + id: ID @primaryKey + name: String + breed: String @indexed + age: Int } -Post.sourcedFrom(BlogSource); ``` -Here both the update to the post and the update to the comments will be atomically/transactionally committed together with the same timestamp. +You can enrich a Dog API request by querying both Dog and BreedCache. The Dog table is your source of truth, while BreedCache ensures you never block on slow or flaky external APIs. -## Cache-Control header +## Step 4: Keep Your Cache Fresh +By default, caches are passive: they refresh only when requested and expired. Sometimes that’s fine. Other times, you’ll want them to stay in sync as soon as data changes at the source. Harper supports both: -When interacting with cached data, you can also use the `Cache-Control` request header to specify certain caching behaviors. When performing a PUT (or POST) method, you can use the `max-age` directive to indicate how long the resource should be cached (until stale): +- Passive caching: great for data that doesn’t change often (like dog breed characteristics). +- Active caching: connect to a subscription or webhook so Harper gets notified when the source changes. Your cache updates immediately, and your users always see fresh data. -```http -PUT /my-resource/id -Cache-Control: max-age=86400 -``` +You can even invalidate records manually, or implement write-through caching so updates flow both ways. -You can use the `only-if-cached` directive on GET requests to only return a resource if it is cached (otherwise will return 504). Note, that if the entry is not cached, this will still trigger a request for the source data from the data source. If you do not want source data retrieved, you can add the `no-store` directive. You can also use the `no-cache` directive if you do not want to use the cached resource. If you wanted to check if there is a cached resource without triggering a request to the data source: +## Step 5: Layer Caches for Your Users -```http -GET /my-resource/id -Cache-Control: only-if-cached, no-store -``` +Caching doesn’t stop at Harper. Because every Harper cache table is a REST API, your clients can cache responses downstream too. Harper automatically tags responses with ETag headers so browsers or edge caches can hold onto data longer. That means your app is fast all the way down—from the source, to Harper, to the client. -You may also use the `stale-if-error` to indicate if it is acceptable to return a stale cached resource when the data source returns an error (network connection error, 500, 502, 503, or 504). The `must-revalidate` directive can indicate a stale cached resource can not be returned, even when the data source has an error (by default a stale cached resource is returned when there is a network connection error). +## Why This Matters -## Caching Flow +With Harper, caching is not a bolt-on. You don’t need Redis, a CDN, and custom scripts to glue everything together. You just define a schema and a source, and Harper handles: -It may be helpful to understand the flow of a cache request. When a request is made to a caching table: +- Expiration and eviction policies +- Preventing cache stampedes +- Active vs passive updates +- Downstream caching with HTTP headers -- Harper will first create a resource instance to handle the process, and ensure that the data is loaded for the resource instance. To do this, it will first check if the record is in the table/cache. - - If the record is not in the cache, Harper will first check if there is a current request to get the record from the source. If there is, Harper will wait for the request to complete and return the record from the cache. - - If not, Harper will call the `get()` method on the source to retrieve the record. The record will then be stored in the cache. - - If the record is in the cache, Harper will check if the record is stale. If the record is not stale, Harper will immediately return the record from the cache. If the record is stale, Harper will call the `get()` method on the source to retrieve the record. - - The record will then be stored in the cache. This will write the record to the cache in a separate asynchronous/background write-behind transaction, so it does not block the current request, then return the data immediately once it has it. -- The `get()` method will be called on the resource instance to return the record to the client (or perform any querying on the record). If this is overriden, the method will be called at this time. +In minutes, you’ve gone from “slow and costly API calls” to a fully distributed, low-latency, schema-driven cache layer. -### Caching Flow with Write-Through +## Next Steps -When a writes are performed on a caching table (in `put()` or `post()` method, for example), the flow is slightly different: +Try expanding your Dog API with a BreedCache table and connect it to a real dog breed service. Then experiment with expiration times, or try active caching if your data source supports subscriptions. -- Harper will have first created a resource instance to handle the process, and this resource instance that will be the current `this` for a call to `put()` or `post()`. -- If a `put()` or `update()` is called, for example, this action will be record in the current transaction. -- Once the transaction is committed (which is done automatically as the request handler completes), the transaction write will be sent to the source to update the data. - - The local writes will wait for the source to confirm the writes have completed (note that this effectively allows you to perform a two-phase transactional write to the source, and the source can confirm the writes have completed before the transaction is committed locally). - - The transaction writes will then be written the local caching table. -- The transaction handler will wait for the local commit to be written, then the transaction will be resolved and a response will be sent to the client. +👉 See the Schema reference for all caching directives and to explore how to build custom caching strategies. \ No newline at end of file diff --git a/versioned_docs/version-4.6/reference/caching.md b/versioned_docs/version-4.6/reference/caching.md new file mode 100644 index 00000000..66d2b64c --- /dev/null +++ b/versioned_docs/version-4.6/reference/caching.md @@ -0,0 +1,272 @@ +--- +title: Caching +--- + +# Caching +Harper provides integrated caching features for external data sources. This page documents all schema directives, configuration options, and resource APIs relevant to caching. + +## Schema Directives + +### `@table(expiration: )` +Defines a table with caching behavior and optional expiration time (TTL). +- expiration: Time in seconds before a record becomes stale. +- Example: + ```graphql + type MyCache @table(expiration: 3600) { + id: ID @primaryKey + } + ``` +#### Expiration Properties +You can configure multiple timing behaviors: +- **expiration** – Time until record goes stale. +- **eviction** – Time after expiration before the record is removed. Defaults to 0. +- **scanInterval** – Interval for scanning expired records. Defaults to ¼ of (expiration + eviction). + +If only `expiration` is provided, it applies to all behaviors. + +## Eviction with Indexing +Eviction removes the non-indexed data of a cached record once it expires. However: +- Indexed attributes remain stored so that indexes stay valid. +- Evicted records exist as partial records. +- If a query matches an evicted record, Harper will re-fetch it from the source on demand. + +This ensures cache eviction does not break query integrity. + + +## Resource API +Caching tables must define an external Resource in `resources.js`. Extend the global `Resource` class and implement methods as needed. + +### `get()` +Fetches a record from the external data source. +```javascript +class MyAPI extends Resource { + async get() { + return (await fetch(`https://api.com/${this.getId()}`)).json(); + } +} +``` + +### `put(data)` +Write-through update to the source. +```javascript +async put(data) { + await fetch(`https://api.com/${this.getId()}`, { + method: 'PUT', + body: JSON.stringify(data) + }); +} +``` + +### `delete()` +Removes a record from the source. + +### `ensureLoaded()` +Ensures the record is loaded from source when using `put`, `post`, or `delete`. + +## Cache Control Context +Each request context supports additional caching metadata: + +- `lastModified`: Set timestamp of last modification. +- `expiresAt`: Explicitly set expiration time. +- `replacingRecord`: Existing cached record for revalidation. +- `replacingVersion` – Previous version timestamp for conditional revalidation (`If-Modified-Since`). + +Example: +```javascript +this.getContext().lastModified = response.headers.get('Last-Modified'); +this.getContext().expiresAt = Date.now() + 60000; // 1 min +``` + +## Expiration from Source Headers +You can set expirations dynamically based on source responses: +```javascript +let cacheInfo = response.headers.get('Cache-Control'); +let maxAge = cacheInfo?.match(/max-age=(\d)/)?.[1]; +if (maxAge) context.expiresAt = Date.now() + maxAge * 1000; +``` +If the origin responds with `304 Not Modified`, return `context.replacingRecord` instead of re-downloading. + +## Invalidation +You can manually invalidate cached records to force re-fetch on next access: +```javascript +const { MyTable } = tables; +export class MyTableEndpoint extends MyTable { + async post(data) { + if (data.invalidate) this.invalidate(); + } +} +``` +Invalidation removes the cached entry but preserves schema consistency. + +## Events and Subscriptions +Caching supports active updates via subscription. + +### Supported Event Types +- `put`: Record updated, includes new value. +- `invalidate`: Mark record stale without sending value. +- `delete`: Record removed. +- `message`: Publish/subscribe message. +- `transaction`: Atomic batch of multiple writes. + +### Event Properties +- **type**: Event type (above). +- **id**: Primary key of record. +- **value**: Record value (for put or message). +- **writes**: Array of events (for transaction). +- **timestamp**: Optional time of change. +- **table**: Table name (for cross-table transactions). + +### subscribe() +Return an async generator or stream to push events. + +```javascript +async *subscribe() { + yield { type: 'put', id: '123', value: { name: 'Harper' } }; +} +``` + +By default, subscriptions run on one worker thread. + +You can control this with: + +```javascript +static subscribeOnThisThread(threadIndex) { + return threadIndex < 2; // run on first two threads +} +``` + +Or use a stream: +```javascript +subscribe() { + const subscription = super.subscribe(); + remoteService.on('update', (event) => subscription.send(event)); + return subscription; +} +``` + +## Downstream Caching +Harper includes built-in support for client-side caching: + +- All cached entries include ETags. +- Clients can use `If-None-Match` to get a `304 Not Modified` instead of re-downloading. +- Subscription support means clients can layer their own caches (passive or active) on top of Harper. + +## Write-Through Caching + +If you implement `put()` or `delete()` on a Resource: + +- Writes are passed to the canonical source. +- The cache updates locally after source confirms success. +- Harper uses a two-phase transaction: + - Source confirms the write. + - Local cache commits the change. + +This ensures consistency between the source and the cache. + +## Passive–Active Updates +Resources can update related tables during a `get()`. +Use the current context to perform transactional multi-table updates: + +```javascript +const { Post, Comment } = tables; +class BlogSource extends Resource { + async get() { + const post = await (await fetch(`https://server/${this.getId()}`)).json(); + for (let comment of post.comments) { + await Comment.put(comment, this); // transactionally linked + } + return post; + } +} +``` +Both `Post` and `Comment` are updated atomically with the same timestamp. + +## Cache-Control Headers +Caching endpoints respect standard HTTP caching headers: + +- `Cache-Control: max-age=` – Define TTL. +- `Cache-Control: only-if-cached` – Return 504 if not cached. +- `Cache-Control: no-store` – Do not store result. +- `Cache-Control: no-cache` – Force revalidation. +- `stale-if-error` – Return stale result if source fails. +- `must-revalidate` – Prevent stale return even if source fails. + +## Behavior Overview +### Passive Cache Flow + +```mermaid +flowchart TD + A[Client request] + B{Record in cache?} + C{In flight fetch exists?} + D[Wait for result] + E[Fetch from source] + F[Store in cache] + G{Record stale?} + H[Return cached record] + + A --> B + B -- No --> C + C -- Yes --> D + C -- No --> E + E --> F + F --> H + B -- Yes --> G + G -- No --> H + G -- Yes --> E + D --> H +``` +Lookup record in cache. + +- If missing/expired → call source `get()`. +- If request in-flight → wait for result. +- On retrieval → save to cache, return to client. +- Expired records may be returned while refresh happens in background. + +### Write-Through Flow + +```mermaid +flowchart TD + A[Client write request] + B[Resource instance created] + C[put or post called] + D[Transaction opened] + E[Write sent to source] + F{Source confirms} + G[Commit to local cache] + H[Resolve transaction and respond] + I[Abort transaction] + + A --> B + B --> C + C --> D + D --> E + E --> F + F -- Yes --> G + G --> H + F -- No --> I + +``` +- Resource instance created. +- `put()` or `post()` called, transaction opened. +- Write sent to source. +- Source confirms → local cache commits. +- Transaction resolved, response sent to client. + +## Example Configurations +### Passive cache with expiration + +```javascript +type BreedCache @table(expiration: 3600) @export { + id: ID @primaryKey +} +``` + +### Active cache with subscription +```javascript +class BreedAPI extends Resource { + async *subscribe() { + yield { type: 'invalidate', id: 'husky' }; + } +} +``` \ No newline at end of file From 7a751391e7997fa25db6232ee8ac01d50c1f6751 Mon Sep 17 00:00:00 2001 From: nenharper Date: Mon, 15 Sep 2025 11:40:18 -0500 Subject: [PATCH 2/3] Add link to caching reference page --- docs/reference/index.md | 1 + versioned_docs/version-4.4/reference/index.md | 1 + versioned_docs/version-4.5/reference/index.md | 1 + versioned_docs/version-4.6/reference/index.md | 1 + 4 files changed, 4 insertions(+) diff --git a/docs/reference/index.md b/docs/reference/index.md index f7dd7df4..4846cd4e 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -9,6 +9,7 @@ This section contains technical details and reference materials for Harper. - [Analytics](reference/analytics) - [Architecture](reference/architecture) - [Blob](reference/blob) +- [Caching](reference/caching) - [Content Types](reference/content-types) - [Components](reference/components/) - [Applications](reference/components/applications) diff --git a/versioned_docs/version-4.4/reference/index.md b/versioned_docs/version-4.4/reference/index.md index efe100e2..cfecc680 100644 --- a/versioned_docs/version-4.4/reference/index.md +++ b/versioned_docs/version-4.4/reference/index.md @@ -6,6 +6,7 @@ title: Reference This section contains technical details and reference materials for Harper. +- [Caching](reference/caching) - [Resource API](reference/resource) - [Transactions](reference/transactions) - [Storage Algorithm](reference/storage-algorithm) diff --git a/versioned_docs/version-4.5/reference/index.md b/versioned_docs/version-4.5/reference/index.md index efe100e2..cfecc680 100644 --- a/versioned_docs/version-4.5/reference/index.md +++ b/versioned_docs/version-4.5/reference/index.md @@ -6,6 +6,7 @@ title: Reference This section contains technical details and reference materials for Harper. +- [Caching](reference/caching) - [Resource API](reference/resource) - [Transactions](reference/transactions) - [Storage Algorithm](reference/storage-algorithm) diff --git a/versioned_docs/version-4.6/reference/index.md b/versioned_docs/version-4.6/reference/index.md index f7dd7df4..4846cd4e 100644 --- a/versioned_docs/version-4.6/reference/index.md +++ b/versioned_docs/version-4.6/reference/index.md @@ -9,6 +9,7 @@ This section contains technical details and reference materials for Harper. - [Analytics](reference/analytics) - [Architecture](reference/architecture) - [Blob](reference/blob) +- [Caching](reference/caching) - [Content Types](reference/content-types) - [Components](reference/components/) - [Applications](reference/components/applications) From 4844f85c2b242b0dfa0c77977cd2cf29fce8de08 Mon Sep 17 00:00:00 2001 From: nenharper Date: Wed, 24 Sep 2025 01:53:41 -0500 Subject: [PATCH 3/3] Address comments --- .../developers/applications/caching.md | 84 +++- .../version-4.6/reference/caching.md | 421 +++++++++--------- 2 files changed, 293 insertions(+), 212 deletions(-) diff --git a/versioned_docs/version-4.6/developers/applications/caching.md b/versioned_docs/version-4.6/developers/applications/caching.md index 3b2525ae..844551f2 100644 --- a/versioned_docs/version-4.6/developers/applications/caching.md +++ b/versioned_docs/version-4.6/developers/applications/caching.md @@ -1,6 +1,7 @@ --- title: Caching --- + # Caching In the [quickstart guide](../../getting-started/first-harper-app.md), you built a working Dog API in just a few minutes. That API is now ready to store and query data. But what happens when your application also needs to pull in data from other systems—say, a third-party service that provides dog breed information? @@ -8,25 +9,29 @@ In the [quickstart guide](../../getting-started/first-harper-app.md), you built If you hit that external API every time a user makes a request, you’ll pay the cost in speed, reliability, and maybe even money. That’s where Harper’s built-in caching comes in. Harper lets you cache external data in the same tables and APIs you’re already using, so your app feels fast, reliable, and cost-efficient without you writing glue code. ## Step 1: Add a Cache Table + Caching in Harper works just like creating any other table. Open up your `schema.graphql` and add a cache table alongside your `Dog` table: ```graphql type BreedCache @table(expiration: 3600) @export { - id: ID @primaryKey + id: ID @primaryKey } ``` -Here, `expiration: 3600` means cached entries expire after an hour. Harper will automatically evict old data and refresh it when needed. By exporting this table, you instantly get a REST API for it at `http://localhost:9926/BreedCache/`. + +Here, `expiration: 3600` means cached entries expire after an hour. By exporting this table, you instantly get a REST API for it at `http://localhost:9926/BreedCache/`. ## Step 2: Connect to an External Source + Now let’s say you want to enrich your Dog records with breed details from an external API. Instead of hitting that API directly every time, we’ll connect it to the `BreedCache` table. Open `resources.js` and define a resource: ```javascript class BreedAPI extends Resource { - async get() { - return (await fetch(`https://dog-api.com/${this.getId()}`)).json(); - } + async get() { + const response = await fetch(`https://dog-api.com/${this.getId()}`); + return response.json(); + } } const { BreedCache } = tables; @@ -42,20 +47,22 @@ That’s it. When your app calls /BreedCache/husky, Harper will: Harper even prevents “cache stampedes”: if multiple users request the same breed at the same time, only one fetch goes out to the source. ## Step 3: Use the Cache in Your API + Now you can combine your Dogs with cached breed info. For example, imagine you added a “breed” attribute to your Dog table earlier: ```graphql type Dog @table @export { - id: ID @primaryKey - name: String - breed: String @indexed - age: Int + id: ID @primaryKey + name: String + breed: String @indexed + age: Int } ``` You can enrich a Dog API request by querying both Dog and BreedCache. The Dog table is your source of truth, while BreedCache ensures you never block on slow or flaky external APIs. ## Step 4: Keep Your Cache Fresh + By default, caches are passive: they refresh only when requested and expired. Sometimes that’s fine. Other times, you’ll want them to stay in sync as soon as data changes at the source. Harper supports both: - Passive caching: great for data that doesn’t change often (like dog breed characteristics). @@ -63,9 +70,64 @@ By default, caches are passive: they refresh only when requested and expired. So You can even invalidate records manually, or implement write-through caching so updates flow both ways. +--- + +## Example: Caching Expensive Dog Work (Non–Third-Party) + +Caching isn’t just for third-party APIs. You can also cache results of expensive work inside your own app—for example, computing detailed statistics about dogs. + +Here’s a resource that simulates a slow calculation (delayed by 2 seconds) to generate a “dog score” based on the name length: + +```javascript +class DogStats extends Resource { + async get() { + // simulate a slow computation + await new Promise((r) => setTimeout(r, 2000)); + return { score: this.getId().length * 42 }; + } +} + +const { DogStatsCache } = tables; +DogStatsCache.sourcedFrom(DogStats); +``` + +Now when you hit `/DogStatsCache/fido`, the first request takes ~2 seconds. After that, requests return instantly from the cache until the entry expires. + ## Step 5: Layer Caches for Your Users -Caching doesn’t stop at Harper. Because every Harper cache table is a REST API, your clients can cache responses downstream too. Harper automatically tags responses with ETag headers so browsers or edge caches can hold onto data longer. That means your app is fast all the way down—from the source, to Harper, to the client. +Caching doesn’t stop at Harper. Because every Harper cache table is a REST API, your clients can cache responses downstream too. Harper automatically tags responses with ETag headers so browsers or edge caches can hold onto data for the appropriate amount of time. That means your app is fast all the way down—from the source, to Harper, to the client. + +## Example: Inspecting Dog Cache Behavior + +Let’s see what caching looks like in practice with the `BreedCache` table. + +First request (miss, goes to source): + +```bash +curl -i http://localhost:9926/BreedCache/husky +``` + +You’ll see a `200 OK` and a body with breed info. Importantly, Harper includes an ETag header, like: + +```bash +ETag: "1727223589-husky" +``` + +Second request (hit, using ETag): + +```bash +curl -i http://localhost:9926/BreedCache/husky \ + -H 'If-None-Match: "1727223589-husky"' +``` + +Now Harper replies with: + +```bash +304 Not Modified + +``` + +No body is returned, and your client can reuse its cached data. Without sending back the ETag, you’ll always get a `200` instead of a `304`—so sending the tag is critical for real cache hits. ## Why This Matters @@ -82,4 +144,4 @@ In minutes, you’ve gone from “slow and costly API calls” to a fully distri Try expanding your Dog API with a BreedCache table and connect it to a real dog breed service. Then experiment with expiration times, or try active caching if your data source supports subscriptions. -👉 See the Schema reference for all caching directives and to explore how to build custom caching strategies. \ No newline at end of file +👉 See the Schema reference for all caching directives and to explore how to build custom caching strategies. diff --git a/versioned_docs/version-4.6/reference/caching.md b/versioned_docs/version-4.6/reference/caching.md index 66d2b64c..3bc694b9 100644 --- a/versioned_docs/version-4.6/reference/caching.md +++ b/versioned_docs/version-4.6/reference/caching.md @@ -3,270 +3,289 @@ title: Caching --- # Caching -Harper provides integrated caching features for external data sources. This page documents all schema directives, configuration options, and resource APIs relevant to caching. -## Schema Directives +Harper includes integrated support for caching data from Harper and 3rd party sources. With built-in caching, distributed high-performance responsiveness, and low latency, Harper can function as a data caching server. -### `@table(expiration: )` -Defines a table with caching behavior and optional expiration time (TTL). -- expiration: Time in seconds before a record becomes stale. -- Example: - ```graphql - type MyCache @table(expiration: 3600) { - id: ID @primaryKey - } - ``` -#### Expiration Properties -You can configure multiple timing behaviors: -- **expiration** – Time until record goes stale. -- **eviction** – Time after expiration before the record is removed. Defaults to 0. -- **scanInterval** – Interval for scanning expired records. Defaults to ¼ of (expiration + eviction). +Cached data is stored in standard tables as queryable structured data. Data can be consumed in one format (e.g., JSON, CSV) and returned in another (e.g., MessagePack with selected properties). Harper also attaches timestamps and tags for caching control, supporting downstream caching. -If only `expiration` is provided, it applies to all behaviors. +## Table Configuration -## Eviction with Indexing -Eviction removes the non-indexed data of a cached record once it expires. However: -- Indexed attributes remain stored so that indexes stay valid. -- Evicted records exist as partial records. -- If a query matches an evicted record, Harper will re-fetch it from the source on demand. +### Defining a Cache Table -This ensures cache eviction does not break query integrity. +Schema example (`schema.graphql`): +```graphql +type MyCache @table(expiration: 3600) @export { + id: ID @primaryKey +} +``` + +- `@table(expiration: 3600)` defines a caching table with TTL. +- Expiration is measured in seconds. +- Expiration needed for passive caches (no active invalidation). +- Expiration is optional if you provide notifications for invalidation. + +### Expiration Properties + +You may configure one or multiple expiration values: + +- **expiration**: Time until a record is considered stale. +- **eviction**: Time after expiration before a record is removed (default = `0`). +- **scanInterval**: Interval for scanning expired records (default = `¼ * (expiration + eviction)`). + +Additional expiration semantics: + +- **stale expiration**: Request may trigger origin fetch; stale record may still be returned. +- **must-revalidate expiratio**n: Request must fetch from origin before returning. +- **eviction expiration**: When record is removed from the table. -## Resource API -Caching tables must define an external Resource in `resources.js`. Extend the global `Resource` class and implement methods as needed. +## External Data Source + +### Defining a Resource + +Extend the `Resource` class in `resources.js`: -### `get()` -Fetches a record from the external data source. ```javascript -class MyAPI extends Resource { - async get() { - return (await fetch(`https://api.com/${this.getId()}`)).json(); - } +class ThirdPartyAPI extends Resource { + async get() { + return (await fetch(`https://some-api.com/${this.getId()}`)).json(); + } } ``` -### `put(data)` -Write-through update to the source. +### Linking to a Cache Table + ```javascript -async put(data) { - await fetch(`https://api.com/${this.getId()}`, { - method: 'PUT', - body: JSON.stringify(data) - }); -} +const { MyCache } = tables; +MyCache.sourcedFrom(ThirdPartyAPI); ``` -### `delete()` -Removes a record from the source. +Behavior: -### `ensureLoaded()` -Ensures the record is loaded from source when using `put`, `post`, or `delete`. +- Accessing `/MyCache/some-id`: + - If cached and valid → return. + - If missing or expired → call `get()`, fetch from source, store result, then return. -## Cache Control Context -Each request context supports additional caching metadata: +Prevents cache stampede by ensuring concurrent requests wait for a single resolution. -- `lastModified`: Set timestamp of last modification. -- `expiresAt`: Explicitly set expiration time. -- `replacingRecord`: Existing cached record for revalidation. -- `replacingVersion` – Previous version timestamp for conditional revalidation (`If-Modified-Since`). +## Eviction and Indexing -Example: -```javascript -this.getContext().lastModified = response.headers.get('Last-Modified'); -this.getContext().expiresAt = Date.now() + 60000; // 1 min -``` +- Eviction = removal of cached copy only. +- Evicted records remain in indexes. +- Index data is preserved as "partial" records. +- If query matches an evicted record → record is fetched on demand. + +## Timestamps -## Expiration from Source Headers -You can set expirations dynamically based on source responses: ```javascript -let cacheInfo = response.headers.get('Cache-Control'); -let maxAge = cacheInfo?.match(/max-age=(\d)/)?.[1]; -if (maxAge) context.expiresAt = Date.now() + maxAge * 1000; +class ThirdPartyAPI extends Resource { + async get() { + let response = await fetch(`https://some-api.com/${this.getId()}`); + this.getContext().lastModified = response.headers.get('Last-Modified'); + return response.json(); + } +} ``` -If the origin responds with `304 Not Modified`, return `context.replacingRecord` instead of re-downloading. -## Invalidation -You can manually invalidate cached records to force re-fetch on next access: +- `context.lastModified` stores record timestamp. + +## Expiration Control + +Set expiration dynamically: + ```javascript -const { MyTable } = tables; -export class MyTableEndpoint extends MyTable { - async post(data) { - if (data.invalidate) this.invalidate(); - } +class ThirdPartyAPI extends Resource { + async get() { + const context = this.getContext(); + let headers = new Headers(); + if (context.replacingVersion) headers.set('If-Modified-Since', new Date(context.replacingVersion).toUTCString()); + + let response = await fetch(`https://some-api.com/${this.getId()}`, { headers }); + let cacheInfo = response.headers.get('Cache-Control'); + let maxAge = cacheInfo?.match(/max-age=(\d)/)?.[1]; + if (maxAge) context.expiresAt = Date.now() + maxAge * 1000; + + if (response.status === 304) return context.replacingRecord; + } } ``` -Invalidation removes the cached entry but preserves schema consistency. -## Events and Subscriptions -Caching supports active updates via subscription. +## Active Caching and Invalidation -### Supported Event Types -- `put`: Record updated, includes new value. -- `invalidate`: Mark record stale without sending value. -- `delete`: Record removed. -- `message`: Publish/subscribe message. -- `transaction`: Atomic batch of multiple writes. +### Passive Cache -### Event Properties -- **type**: Event type (above). -- **id**: Primary key of record. -- **value**: Record value (for put or message). -- **writes**: Array of events (for transaction). -- **timestamp**: Optional time of change. -- **table**: Table name (for cross-table transactions). +- Relies on expiration timers. +- May contain stale data temporarily. -### subscribe() -Return an async generator or stream to push events. +### Active Cache + +- Data source notifies cache of changes. +- Cache updates immediately. + +### Invalidate Example ```javascript -async *subscribe() { - yield { type: 'put', id: '123', value: { name: 'Harper' } }; +const { MyTable } = tables; +export class MyTableEndpoint extends MyTable { + async post(data) { + if (data.invalidate) this.invalidate(); + } } ``` -By default, subscriptions run on one worker thread. +## Subscriptions -You can control this with: +### Implementing Subscribe ```javascript -static subscribeOnThisThread(threadIndex) { - return threadIndex < 2; // run on first two threads +class ThirdPartyAPI extends Resource { + async *subscribe() { + setInterval(async () => { + let update = (await fetch(`https://some-api.com/latest-update`)).json(); + yield { + type: 'put', + id: update.id, + value: update.value, + timestamp: update.timestamp + }; + }, 1000); + } } ``` -Or use a stream: +### Supported Event Types + +- `put`: Record updated (with `value`). +- `invalidate`: Record invalidated (no value sent). +- `delete`: Record deleted. +- `message`: Message passed (no record change). +- `transaction`: Batch of events (`writes` property). + +### Event Object Properties + +- `type`: Event type. +- `id`: Primary key of updated record. +- `value`: Updated record (for `put`, `message`). +- `writes`: Array of events (for transaction). +- `table`: Table name (within transactions). +- `timestamp`: Time of change. + +### Multithreading + +- By default, subscribe runs on one thread. +- Use `subscribeOnThisThread` to scale: + ```javascript + class ThirdPartyAPI extends Resource { + static subscribeOnThisThread(threadIndex) { + return threadIndex < 2; // run on two threads + } + } + ``` + +### Stream Alternative + ```javascript -subscribe() { - const subscription = super.subscribe(); - remoteService.on('update', (event) => subscription.send(event)); - return subscription; +class ThirdPartyAPI extends Resource { + subscribe() { + const subscription = super.subscribe(); + setupListeningToRemoteService().on('update', (event) => { + subscription.send(event); + }); + return subscription; + } } ``` ## Downstream Caching -Harper includes built-in support for client-side caching: -- All cached entries include ETags. -- Clients can use `If-None-Match` to get a `304 Not Modified` instead of re-downloading. -- Subscription support means clients can layer their own caches (passive or active) on top of Harper. +- Use REST interface for layered caching. +- Timestamps provide `ETag` headers. +- Clients can use `If-None-Match` for 304 responses. +- Subscription-based updates propagate to downstream caches. ## Write-Through Caching -If you implement `put()` or `delete()` on a Resource: +### Writing Methods + +```javascript +class ThirdPartyAPI extends Resource { + async put(data) { + await fetch(`https://some-api.com/${this.getId()}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + async delete() { + await fetch(`https://some-api.com/${this.getId()}`, { method: 'DELETE' }); + } +} +``` + +Writes: + +- Forwarded to origin source. +- Stored in cache. + +## Loading from Source in Other Methods + +Use `ensureLoaded()`: -- Writes are passed to the canonical source. -- The cache updates locally after source confirms success. -- Harper uses a two-phase transaction: - - Source confirms the write. - - Local cache commits the change. +```javascript +class MyCache extends tables.MyCache { + async post(data) { + await this.ensuredLoaded(); + this.quantity = this.quantity - data.purchases; + } +} +``` -This ensures consistency between the source and the cache. +## Passive-Active Updates -## Passive–Active Updates -Resources can update related tables during a `get()`. -Use the current context to perform transactional multi-table updates: +Transactional updates using `context`: ```javascript const { Post, Comment } = tables; class BlogSource extends Resource { - async get() { - const post = await (await fetch(`https://server/${this.getId()}`)).json(); - for (let comment of post.comments) { - await Comment.put(comment, this); // transactionally linked - } - return post; - } + async get() { + const post = await (await fetch(`https://my-blog-server/${this.getId()}`)).json(); + for (let comment of post.comments) { + await Comment.put(comment, this); + } + return post; + } } +Post.sourcedFrom(BlogSource); ``` -Both `Post` and `Comment` are updated atomically with the same timestamp. - -## Cache-Control Headers -Caching endpoints respect standard HTTP caching headers: - -- `Cache-Control: max-age=` – Define TTL. -- `Cache-Control: only-if-cached` – Return 504 if not cached. -- `Cache-Control: no-store` – Do not store result. -- `Cache-Control: no-cache` – Force revalidation. -- `stale-if-error` – Return stale result if source fails. -- `must-revalidate` – Prevent stale return even if source fails. - -## Behavior Overview -### Passive Cache Flow - -```mermaid -flowchart TD - A[Client request] - B{Record in cache?} - C{In flight fetch exists?} - D[Wait for result] - E[Fetch from source] - F[Store in cache] - G{Record stale?} - H[Return cached record] - - A --> B - B -- No --> C - C -- Yes --> D - C -- No --> E - E --> F - F --> H - B -- Yes --> G - G -- No --> H - G -- Yes --> E - D --> H -``` -Lookup record in cache. -- If missing/expired → call source `get()`. -- If request in-flight → wait for result. -- On retrieval → save to cache, return to client. -- Expired records may be returned while refresh happens in background. +## Cache-Control Header -### Write-Through Flow +- PUT / POST: + `Cache-Control: max-age=86400` → record cached until stale. +- GET: + - `only-if-cached`: Return if cached, else `504`. + - `no-store`: Do not retrieve from source. + - `no-cache`: Do not use cached record. + - `stale-if-error`: Allow stale if origin fails. + - `must-revalidate`: Forbid stale return even on error. -```mermaid -flowchart TD - A[Client write request] - B[Resource instance created] - C[put or post called] - D[Transaction opened] - E[Write sent to source] - F{Source confirms} - G[Commit to local cache] - H[Resolve transaction and respond] - I[Abort transaction] - - A --> B - B --> C - C --> D - D --> E - E --> F - F -- Yes --> G - G --> H - F -- No --> I +## Caching Flow -``` -- Resource instance created. -- `put()` or `post()` called, transaction opened. -- Write sent to source. -- Source confirms → local cache commits. -- Transaction resolved, response sent to client. +### Read Flow -## Example Configurations -### Passive cache with expiration +1. Create resource instance. +2. If cached: + - If fresh → return immediately. + - If stale → fetch from source, update cache asynchronously, return value. +3. If not cached: + - If another request pending → wait for result. + - Else → fetch, cache, return. -```javascript -type BreedCache @table(expiration: 3600) @export { - id: ID @primaryKey -} -``` +### Write-Through Flow -### Active cache with subscription -```javascript -class BreedAPI extends Resource { - async *subscribe() { - yield { type: 'invalidate', id: 'husky' }; - } -} -``` \ No newline at end of file +1. Resource instance created. +2. `put()` or `post()` recorded in transaction. +3. Transaction commits: + - Write sent to source. + - Source confirms. + - Record written to cache table. +4. Response returned after local commit.