diff --git a/.changeset/great-kids-rhyme.md b/.changeset/great-kids-rhyme.md new file mode 100644 index 000000000..5dd1e4654 --- /dev/null +++ b/.changeset/great-kids-rhyme.md @@ -0,0 +1,8 @@ +--- +"@supabase-cache-helpers/postgrest-react-query": patch +"@supabase-cache-helpers/storage-react-query": patch +"@supabase-cache-helpers/postgrest-swr": patch +"@supabase-cache-helpers/storage-swr": patch +--- + +update readme to reflect new server-side package diff --git a/.changeset/soft-trees-crash.md b/.changeset/soft-trees-crash.md new file mode 100644 index 000000000..e8cb5b6eb --- /dev/null +++ b/.changeset/soft-trees-crash.md @@ -0,0 +1,5 @@ +--- +"@supabase-cache-helpers/postgrest-server": patch +--- + +initial release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76590a078..57dd3f0c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,37 +51,50 @@ jobs: run: pnpm turbo run test --concurrency=1 - name: Upload postgrest-core coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - files: ./packages/postgrest-core/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + file: ./packages/postgrest-core/coverage/coverage-final.json flags: postgrest-core - name: Upload postgrest-react-query coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - files: ./packages/postgrest-react-query/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + file: ./packages/postgrest-react-query/coverage/coverage-final.json flags: postgrest-react-query - name: Upload postgrest-swr coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - files: ./packages/postgrest-swr/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + file: ./packages/postgrest-swr/coverage/coverage-final.json flags: postgrest-swr - name: Upload storage-core coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - files: ./packages/storage-core/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + file: ./packages/storage-core/coverage/coverage-final.json flags: storage-core - name: Upload storage-swr coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - files: ./packages/storage-swr/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + file: ./packages/storage-swr/coverage/coverage-final.json flags: storage-swr - name: Upload storage-react-query coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - files: ./packages/storage-react-query/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + file: ./packages/storage-react-query/coverage/coverage-final.json flags: storage-react-query + + - name: Upload postgrest-server coverage + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./packages/postgrest-server/coverage/coverage-final.json + flags: postgrest-server diff --git a/README.md b/README.md index 9a734701c..1ecba47b3 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ ## Introduction -The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app) and find out how it feels like for your users. +The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. It also provides a simple server-side abstraction to cache queries to the `PostgREST` API. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app) and find out how it feels like for your users. ## Features With just one single line of code, you can simplify the logic of **fetching, subscribing to updates, and mutating data as well as storage objects** in your project, and have all the amazing features of [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest) out-of-the-box. - **Seamless** integration with [SWR](https://swr.vercel.app) and [React Query](https://tanstack.com/query/latest) +- Support for **Server-Side** queries - **Automatic** cache key generation - Easy **Pagination** and **Infinite Scroll** queries - **Insert**, **update**, **upsert** and **delete** mutations @@ -42,6 +43,7 @@ The cache helpers are split up into reusable libraries. - [`storage-swr`](./packages/storage-swr/README.md): [SWR](https://swr.vercel.app) wrapper for storage [storage-js](https://github.com/supabase/storage-js) - [`postgrest-react-query`](./packages/postgrest-react-query/README.md): [React Query](https://tanstack.com/query/latest) wrapper for [postgrest-js](https://github.com/supabase/postgrest-js) - [`storage-react-query`](./packages/storage-react-query/README.md): [React Query](https://tanstack.com/query/latest) wrapper for storage [storage-js](https://github.com/supabase/storage-js) +- [`postgrest-server`](./packages/postgrest-server/README.md): Server-side caching wrapper for [postgrest-js](https://github.com/supabase/postgrest-js). ### Shared Packages @@ -67,25 +69,5 @@ Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
(we are hiring!) - - - Supabase - -
- Supabase -
- https://supabase.com -
- - - - Marviel - -
- Luke Bechtel -
- @Marviel -
- diff --git a/docs/pages/postgrest/_meta.ts b/docs/pages/postgrest/_meta.ts index bb0117403..dde329c44 100644 --- a/docs/pages/postgrest/_meta.ts +++ b/docs/pages/postgrest/_meta.ts @@ -5,4 +5,5 @@ export default { subscriptions: 'Subscriptions', 'custom-cache-updates': 'Custom Cache Updates', ssr: 'Server Side Rendering', + server: 'Server Side Caching', }; diff --git a/docs/pages/postgrest/server.mdx b/docs/pages/postgrest/server.mdx new file mode 100644 index 000000000..d4ead9875 --- /dev/null +++ b/docs/pages/postgrest/server.mdx @@ -0,0 +1,157 @@ +import { Tabs } from 'nextra/components'; + +# Server-Side Caching + +Cache helpers also provides a simple caching abstraction to be used server-side via `@supabase-cache-helpers/postgrest-server`. + +## Motivation + +At some point, you might want to cache your PostgREST requests on the server-side too. Most users either do not cache at all, or caching might look like this: + + +```ts +const cache = new Some3rdPartyCache(...) + +let contact = await cache.get(contactId) as Tables<"contact"> | undefined | null; +if (!contact){ + const { data } = await supabase.from("contact").select("*").eq("id", contactId).throwOnError() + contact = data + await cache.set(contactId, contact, Date.now() + 60_000) +} + +// use contact +``` + +There are a few annoying things about this code: + +- Manual type casting +- No support for stale-while-revalidate + +Most people would build a small wrapper around this to make it easier to use and so did we: This library is the result of a rewrite of our own caching layer after some developers were starting to replicate it. It’s used in production by Hellomateo any others. + +## Features + +- **Typescript**: Fully typesafe +- **Tiered Cache**: Multiple caches in series to fall back on +- **Stale-While-Revalidate**: Async loading of data from your origin +- **Deduping**: Prevents multiple requests for the same data from being made at the same time + +## Getting Started + +Fist, install the dependency: + + + `npm install @supabase-cache-helpers/postgrest-server` + `pnpm add @supabase-cache-helpers/postgrest-server` + `yarn add @supabase-cache-helpers/postgrest-server` + `bun install @supabase-cache-helpers/postgrest-server` + + +This is how you can make your first cached query: + +```ts +import { QueryCache } from '@supabase-cache-helpers/postgrest-server'; +import { MemoryStore } from '@supabase-cache-helpers/postgrest-server/stores'; +import { createClient } from '@supabase/supabase-js'; +import { Database } from './types'; + +const client = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY +); + +const map = new Map(); + +const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + // Configure the defaults + fresh: 1000, + stale: 2000, +}); + +const res = await cache.query( + client + .from('contact') + .select('id,username') + .eq('username', contacts[0].username!) + .single(), + // overwrite the default per query + { fresh: 100, stale : 200 } +); + +``` + +### Context + +You may wonder what `ctx` is passed above. In serverless functions it’s not always trivial to run some code after you have returned a response. This is where the context comes in. It allows you to register promises that should be awaited before the function is considered done. Fortunately many providers offer a way to do this. + +In order to be used in this cache library, the context must implement the following interface: + +```ts +export interface Context { + waitUntil: (p: Promise) => void; +} +``` + +For stateful applications, you can use the `DefaultStatefulContext`: + +```ts +import { DefaultStatefulContext } from "@unkey/cache"; +const ctx = new DefaultStatefulContext() +``` + +## Tiered Cache + +Different caches have different characteristics, some may be fast but volatile, others may be slow but persistent. By using a tiered cache, you can combine the best of both worlds. In almost every case, you want to use a fast in-memory cache as the first tier. There is no reason not to use it, as it doesn’t add any latency to your application. + +The goal of this implementation is that it’s invisible to the user. Everything behaves like a single cache. You can add as many tiers as you want. + +### Example + +```ts +import { QueryCache } from '@supabase-cache-helpers/postgrest-server'; +import { MemoryStore, RedisStore } from '@supabase-cache-helpers/postgrest-server/stores'; +import { Redis } from 'ioredis'; +import { createClient } from '@supabase/supabase-js'; +import { Database } from './types'; + +const client = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY +); + +const map = new Map(); + +const redis = new Redis({...}); + +const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map }), new RedisStore({ redis })], + fresh: 1000, + stale: 2000 +}); + +const res = await cache.query( + client + .from('contact') + .select('id,username') + .eq('username', contacts[0].username!) + .single() +); + +``` + + +## Stale-While-Revalidate + +To make data fetching as easy as possible, the cache offers a swr method, that acts as a pull through cache. If the data is fresh, it will be returned from the cache, if it’s stale it will be returned from the cache and a background refresh will be triggered and if it’s not in the cache, the data will be synchronously fetched from the origin. + +```ts +const res = await cache.swr( + client + .from('contact') + .select('id,username') + .eq('username', contacts[0].username!) + .single() +); +``` + diff --git a/packages/postgrest-react-query/README.md b/packages/postgrest-react-query/README.md index 800b3e94a..acd411394 100644 --- a/packages/postgrest-react-query/README.md +++ b/packages/postgrest-react-query/README.md @@ -5,16 +5,16 @@ A collection of React Query utilities for working with Supabase. Latest build GitHub Stars [![codecov](https://codecov.io/gh/psteinroe/supabase-cache-helpers/branch/main/graph/badge.svg?token=SPMWSVBRGX)](https://codecov.io/gh/psteinroe/supabase-cache-helpers) - ## Introduction -The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-react-query.vercel.app/) and find out how it feels like for your users. +The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. It also provides a simple server-side abstraction to cache queries to the `PostgREST` API. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app) and find out how it feels like for your users. ## Features With just one single line of code, you can simplify the logic of **fetching, subscribing to updates, and mutating data as well as storage objects** in your project, and have all the amazing features of [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest) out-of-the-box. - **Seamless** integration with [SWR](https://swr.vercel.app) and [React Query](https://tanstack.com/query/latest) +- Support for **Server-Side** queries - **Automatic** cache key generation - Easy **Pagination** and **Infinite Scroll** queries - **Insert**, **update**, **upsert** and **delete** mutations @@ -27,3 +27,4 @@ And a lot [more](https://supabase-cache-helpers.vercel.app). --- **View full documentation and examples on [supabase-cache-helpers.vercel.app](https://supabase-cache-helpers.vercel.app).** + diff --git a/packages/postgrest-server/README.md b/packages/postgrest-server/README.md new file mode 100644 index 000000000..dfde33da9 --- /dev/null +++ b/packages/postgrest-server/README.md @@ -0,0 +1,34 @@ +# PostgREST Server Cache + +A collection of server-side caching utilities for working with Supabase. + +Latest build +GitHub Stars +[![codecov](https://codecov.io/gh/psteinroe/supabase-cache-helpers/branch/main/graph/badge.svg?token=SPMWSVBRGX)](https://codecov.io/gh/psteinroe/supabase-cache-helpers) + +## Introduction + +The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. It also provides a simple server-side abstraction to cache queries to the `PostgREST` API. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app) and find out how it feels like for your users. + +## Features + +With just one single line of code, you can simplify the logic of **fetching, subscribing to updates, and mutating data as well as storage objects** in your project, and have all the amazing features of [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest) out-of-the-box. + +- **Seamless** integration with [SWR](https://swr.vercel.app) and [React Query](https://tanstack.com/query/latest) +- Support for **Server-Side** queries +- **Automatic** cache key generation +- Easy **Pagination** and **Infinite Scroll** queries +- **Insert**, **update**, **upsert** and **delete** mutations +- **Auto-populate** cache after mutations and subscriptions +- **Auto-expand** mutation queries based on existing cache data to keep app up-to-date +- One-liner to upload, download and remove **Supabase Storage** objects + +And a lot [more](https://supabase-cache-helpers.vercel.app). + +--- + +**View full documentation and examples on [supabase-cache-helpers.vercel.app](https://supabase-cache-helpers.vercel.app).** + +## Acknowledgement + +The hard part of this package has been extracted from `@unkey/cache`. If you are on the lookout for a generic typesafe caching solution be sure to check it out. diff --git a/packages/postgrest-server/package.json b/packages/postgrest-server/package.json new file mode 100644 index 000000000..6dd7cfca9 --- /dev/null +++ b/packages/postgrest-server/package.json @@ -0,0 +1,60 @@ +{ + "name": "@supabase-cache-helpers/postgrest-server", + "version": "0.0.0", + "author": "Philipp Steinrötter ", + "homepage": "https://supabase-cache-helpers.vercel.app", + "bugs": { + "url": "https://github.com/psteinroe/supabase-cache-helpers/issues" + }, + "type": "module", + "main": "./dist/index.js", + "source": "./src/index.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./stores": { + "types": "./dist/stores.d.ts", + "import": "./dist/stores.js", + "require": "./dist/stores.cjs" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "files": ["dist/**"], + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "scripts": { + "build": "tsup", + "test": "vitest --coverage --no-file-parallelism --dangerouslyIgnoreUnhandledErrors", + "clean": "rm -rf .turbo && rm -rf .nyc_output && rm -rf node_modules && rm -rf dist", + "typecheck": "tsc --pretty --noEmit" + }, + "keywords": ["Supabase", "PostgREST", "Cache", "SWR"], + "repository": { + "type": "git", + "url": "git+https://github.com/psteinroe/supabase-cache-helpers.git", + "directory": "packages/postgrest-server" + }, + "peerDependencies": { + "@supabase/postgrest-js": "^1.9.0" + }, + "devDependencies": { + "@supabase-cache-helpers/tsconfig": "workspace:*", + "@supabase/postgrest-js": "1.16.1", + "@supabase/supabase-js": "2.45.4", + "@vitest/coverage-istanbul": "^2.0.2", + "ioredis": "5.4.1", + "dotenv": "16.4.0", + "tsup": "8.2.0", + "typescript": "5.5.3", + "vitest": "2.0.2" + }, + "dependencies": { + "@supabase-cache-helpers/postgrest-core": "workspace:*" + } +} diff --git a/packages/postgrest-server/src/context.ts b/packages/postgrest-server/src/context.ts new file mode 100644 index 000000000..40fce295d --- /dev/null +++ b/packages/postgrest-server/src/context.ts @@ -0,0 +1,9 @@ +export interface Context { + waitUntil: (p: Promise) => void; +} + +export class DefaultStatefulContext implements Context { + public waitUntil(_p: Promise) { + // do nothing, the promise will resolve on its own + } +} diff --git a/packages/postgrest-server/src/index.ts b/packages/postgrest-server/src/index.ts new file mode 100644 index 000000000..4777d0833 --- /dev/null +++ b/packages/postgrest-server/src/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './query-cache'; diff --git a/packages/postgrest-server/src/key.ts b/packages/postgrest-server/src/key.ts new file mode 100644 index 000000000..e6644cfdc --- /dev/null +++ b/packages/postgrest-server/src/key.ts @@ -0,0 +1,26 @@ +import { + AnyPostgrestResponse, + PostgrestParser, + isPostgrestBuilder, +} from '@supabase-cache-helpers/postgrest-core'; + +const SEPARATOR = '$'; + +export function encode( + query: PromiseLike>, +) { + if (!isPostgrestBuilder(query)) { + throw new Error('Query is not a PostgrestBuilder'); + } + + const parser = new PostgrestParser(query); + return [ + parser.schema, + parser.table, + parser.queryKey, + parser.bodyKey ?? 'null', + `count=${parser.count}`, + `head=${parser.isHead}`, + parser.orderByKey, + ].join(SEPARATOR); +} diff --git a/packages/postgrest-server/src/query-cache.ts b/packages/postgrest-server/src/query-cache.ts new file mode 100644 index 000000000..4d40f4003 --- /dev/null +++ b/packages/postgrest-server/src/query-cache.ts @@ -0,0 +1,136 @@ +import { type AnyPostgrestResponse } from '@supabase-cache-helpers/postgrest-core'; +import type { + PostgrestMaybeSingleResponse, + PostgrestResponse, + PostgrestSingleResponse, +} from '@supabase/postgrest-js'; + +import { Context } from './context'; +import { encode } from './key'; +import { Value } from './stores/entry'; +import { Store } from './stores/interface'; +import { SwrCache } from './swr-cache'; +import { TieredStore } from './tiered-store'; + +export type QueryCacheOpts = { + stores: Store[]; + fresh: number; + stale: number; +}; + +export type OperationOpts = Pick; + +export class QueryCache { + private readonly inner: SwrCache; + + /** + * To prevent concurrent requests of the same data, all queries are deduplicated using + * this map. + */ + private readonly runningQueries: Map< + string, + PromiseLike> + > = new Map(); + + constructor(ctx: Context, opts: QueryCacheOpts) { + const tieredStore = new TieredStore(ctx, opts.stores); + + this.inner = new SwrCache({ + ctx, + store: tieredStore, + fresh: opts.fresh, + stale: opts.stale, + }); + } + + /** + * Perform a cached postgrest query + */ + query( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise>; + /** + * Perform a cached postgrest query + */ + query( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise>; + /** + * Perform a cached postgrest query + */ + query( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise>; + async query( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise> { + const key = encode(query); + + const value = await this.inner.get(key); + + if (value) return value; + + const result = await this.dedupeQuery(query); + + await this.inner.set(key, result, opts); + + return result; + } + + /** + * Perform a cached postgrest query + */ + swr( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise>; + /** + * Perform a cached postgrest query + */ + swr( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise>; + /** + * Perform a cached postgrest query + */ + swr( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise>; + async swr( + query: PromiseLike>, + opts?: OperationOpts, + ): Promise> { + return await this.inner.swr( + encode(query), + () => this.dedupeQuery(query), + opts, + ); + } + + /** + * Deduplicating the origin load helps when the same value is requested many times at once and is + * not yet in the cache. If we don't deduplicate, we'd create a lot of unnecessary load on the db. + */ + private async dedupeQuery( + query: PromiseLike>, + ): Promise> { + const key = encode(query); + try { + const querying = this.runningQueries.get(key); + if (querying) { + return querying; + } + + this.runningQueries.set(key, query); + return await query; + } finally { + this.runningQueries.delete(key); + } + } +} diff --git a/packages/postgrest-server/src/stores/entry.ts b/packages/postgrest-server/src/stores/entry.ts new file mode 100644 index 000000000..789886cc4 --- /dev/null +++ b/packages/postgrest-server/src/stores/entry.ts @@ -0,0 +1,20 @@ +import { type AnyPostgrestResponse } from '@supabase-cache-helpers/postgrest-core'; + +export type Value = AnyPostgrestResponse; + +export type Entry = { + value: Value; + + // Before this time the entry is considered fresh and valid + // UnixMilli + freshUntil: number; + + /** + * Unix timestamp in milliseconds. + * + * Do not use data after this point as it is considered no longer valid. + * + * You can use this field to configure automatic eviction in your store implementation. * + */ + staleUntil: number; +}; diff --git a/packages/postgrest-server/src/stores/index.ts b/packages/postgrest-server/src/stores/index.ts new file mode 100644 index 000000000..f9ff0902c --- /dev/null +++ b/packages/postgrest-server/src/stores/index.ts @@ -0,0 +1,4 @@ +export * from './interface'; +export * from './entry'; +export * from './memory'; +export * from './redis'; diff --git a/packages/postgrest-server/src/stores/interface.ts b/packages/postgrest-server/src/stores/interface.ts new file mode 100644 index 000000000..e1234d03f --- /dev/null +++ b/packages/postgrest-server/src/stores/interface.ts @@ -0,0 +1,35 @@ +import { Entry } from './entry'; + +/** + * A store is a common interface for storing, reading and deleting key-value pairs. + * + * The store implementation is responsible for cleaning up expired data on its own. + */ +export interface Store { + /** + * A name for metrics/tracing. + * + * @example: memory | zone + */ + name: string; + + /** + * Return the cached value + * + * The response must be `undefined` for cache misses + */ + get(key: string): Promise | undefined>; + + /** + * Sets the value for the given key. + * + * You are responsible for evicting expired values in your store implementation. + * Use the `entry.staleUntil` (unix milli timestamp) field to configure expiration + */ + set(key: string, value: Entry): Promise; + + /** + * Removes the key from the store. + */ + remove(key: string | string[]): Promise; +} diff --git a/packages/postgrest-server/src/stores/memory.ts b/packages/postgrest-server/src/stores/memory.ts new file mode 100644 index 000000000..ac380e4eb --- /dev/null +++ b/packages/postgrest-server/src/stores/memory.ts @@ -0,0 +1,47 @@ +import type { Entry } from './entry'; +import type { Store } from './interface'; + +export type MemoryStoreConfig = { + persistentMap: Map; +}; + +export class MemoryStore implements Store { + private readonly state: Map }>; + + public readonly name = 'memory'; + + constructor( + config: MemoryStoreConfig<{ expires: number; entry: Entry }>, + ) { + this.state = config.persistentMap; + } + + public async get(key: string): Promise | undefined> { + const value = this.state.get(key); + if (!value) { + return Promise.resolve(undefined); + } + if (value.expires <= Date.now()) { + await this.remove(key); + } + return Promise.resolve(value.entry); + } + + public async set(key: string, entry: Entry): Promise { + this.state.set(key, { + expires: entry.staleUntil, + entry, + }); + + return Promise.resolve(); + } + + public async remove(keys: string | string[]): Promise { + const cacheKeys = Array.isArray(keys) ? keys : [keys]; + + for (const key of cacheKeys) { + this.state.delete(key); + } + return Promise.resolve(); + } +} diff --git a/packages/postgrest-server/src/stores/redis.ts b/packages/postgrest-server/src/stores/redis.ts new file mode 100644 index 000000000..3b321e347 --- /dev/null +++ b/packages/postgrest-server/src/stores/redis.ts @@ -0,0 +1,47 @@ +import type { Redis } from 'ioredis'; + +import type { Entry } from './entry'; +import type { Store } from './interface'; + +export type RedisStoreConfig = { + redis: Redis; + prefix?: string; +}; + +export class RedisStore implements Store { + private readonly redis: Redis; + public readonly name = 'redis'; + private readonly prefix: string; + + constructor(config: RedisStoreConfig) { + this.redis = config.redis; + this.prefix = config.prefix || 'sbch'; + } + + private buildCacheKey(key: string): string { + return [this.prefix, key].join('::'); + } + + public async get(key: string): Promise | undefined> { + const res = await this.redis.get(this.buildCacheKey(key)); + if (!res) return; + + return JSON.parse(res) as Entry; + } + + public async set(key: string, entry: Entry): Promise { + await this.redis.set( + this.buildCacheKey(key), + JSON.stringify(entry), + 'PXAT', + entry.staleUntil, + ); + } + + public async remove(keys: string | string[]): Promise { + const cacheKeys = (Array.isArray(keys) ? keys : [keys]).map((key) => + this.buildCacheKey(key).toString(), + ); + this.redis.del(...cacheKeys); + } +} diff --git a/packages/postgrest-server/src/swr-cache.ts b/packages/postgrest-server/src/swr-cache.ts new file mode 100644 index 000000000..4ad82911b --- /dev/null +++ b/packages/postgrest-server/src/swr-cache.ts @@ -0,0 +1,111 @@ +import type { Context } from './context'; +import { Value } from './stores/entry'; +import { Store } from './stores/interface'; + +export type SwrCacheOpts = { + ctx: Context; + store: Store; + fresh: number; + stale: number; +}; + +/** + * Internal cache implementation for an individual namespace + */ +export class SwrCache { + private readonly ctx: Context; + private readonly store: Store; + private readonly fresh: number; + private readonly stale: number; + + constructor({ ctx, store, fresh, stale }: SwrCacheOpts) { + this.ctx = ctx; + this.store = store; + this.fresh = fresh; + this.stale = stale; + } + + /** + * Return the cached value + * + * The response will be `undefined` for cache misses or `null` when the key was not found in the origin + */ + public async get(key: string): Promise | undefined> { + const res = await this._get(key); + return res.value; + } + + private async _get( + key: string, + ): Promise<{ value: Value | undefined; revalidate?: boolean }> { + const res = await this.store.get(key); + + const now = Date.now(); + if (!res) { + return { value: undefined }; + } + + if (now >= res.staleUntil) { + this.ctx.waitUntil(this.remove(key)); + return { value: undefined }; + } + if (now >= res.freshUntil) { + return { value: res.value, revalidate: true }; + } + + return { value: res.value }; + } + + /** + * Set the value + */ + public async set( + key: string, + value: Value, + opts?: { + fresh: number; + stale: number; + }, + ): Promise { + const now = Date.now(); + return this.store.set(key, { + value, + freshUntil: now + (opts?.fresh ?? this.fresh), + staleUntil: now + (opts?.stale ?? this.stale), + }); + } + + /** + * Removes the key from the cache. + */ + public async remove(key: string): Promise { + return this.store.remove(key); + } + + public async swr( + key: string, + loadFromOrigin: (key: string) => Promise>, + opts?: { + fresh: number; + stale: number; + }, + ): Promise> { + const res = await this._get(key); + + const { value, revalidate } = res; + + if (typeof value !== 'undefined') { + if (revalidate) { + this.ctx.waitUntil( + loadFromOrigin(key).then((res) => this.set(key, res, opts)), + ); + } + + return value; + } + + const loadedValue = await loadFromOrigin(key); + this.ctx.waitUntil(this.set(key, loadedValue)); + return loadedValue; + } +} diff --git a/packages/postgrest-server/src/tiered-store.ts b/packages/postgrest-server/src/tiered-store.ts new file mode 100644 index 000000000..9a0875f6e --- /dev/null +++ b/packages/postgrest-server/src/tiered-store.ts @@ -0,0 +1,74 @@ +import type { Context } from './context'; +import { Entry } from './stores/entry'; +import { Store } from './stores/interface'; + +/** + * TieredCache is a cache that will first check the memory cache, then the zone cache. + */ +export class TieredStore implements Store { + private ctx: Context; + private readonly tiers: Store[]; + public readonly name = 'tiered'; + + /** + * Create a new tiered store + * Stored are checked in the order they are provided + * The first store to return a value will be used to populate all previous stores + * + * + * `stores` can accept `undefined` as members to allow you to construct the tiers dynamically + * @example + * ```ts + * new TieredStore(ctx, [ + * new MemoryStore(..), + * process.env.ENABLE_X_STORE ? new XStore(..) : undefined + * ]) + * ``` + */ + constructor(ctx: Context, stores: (Store | undefined)[]) { + this.ctx = ctx; + this.tiers = stores.filter(Boolean) as Store[]; + } + + /** + * Return the cached value + * + * The response will be `undefined` for cache misses or `null` when the key was not found in the origin + */ + public async get(key: string): Promise | undefined> { + if (this.tiers.length === 0) { + return; + } + + for (let i = 0; i < this.tiers.length; i++) { + const res = await this.tiers[i].get(key); + + if (!res) { + return; + } + + // Fill all lower caches + this.ctx.waitUntil( + Promise.all( + this.tiers.filter((_, j) => j < i).map((t) => () => t.set(key, res)), + ), + ); + + return res; + } + } + + /** + * Sets the value for the given key. + */ + public async set(key: string, value: Entry): Promise { + await Promise.all(this.tiers.map((t) => t.set(key, value))); + } + + /** + * Removes the key from the cache. + */ + public async remove(key: string): Promise { + await Promise.all(this.tiers.map((t) => t.remove(key))); + } +} diff --git a/packages/postgrest-server/tests/database.types.ts b/packages/postgrest-server/tests/database.types.ts new file mode 100644 index 000000000..0331d5d7b --- /dev/null +++ b/packages/postgrest-server/tests/database.types.ts @@ -0,0 +1,376 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export interface Database { + public: { + Tables: { + contact: { + Row: { + age_range: unknown | null; + catchphrase: unknown | null; + continent: string | null; + country: string | null; + created_at: string; + golden_ticket: boolean | null; + id: string; + metadata: Json | null; + tags: string[] | null; + ticket_number: number | null; + username: string | null; + has_low_ticket_number: unknown | null; + }; + Insert: { + age_range?: unknown | null; + catchphrase?: unknown | null; + continent?: string | null; + country?: string | null; + created_at?: string; + golden_ticket?: boolean | null; + id?: string; + metadata?: Json | null; + tags?: string[] | null; + ticket_number?: number | null; + username?: string | null; + }; + Update: { + age_range?: unknown | null; + catchphrase?: unknown | null; + continent?: string | null; + country?: string | null; + created_at?: string; + golden_ticket?: boolean | null; + id?: string; + metadata?: Json | null; + tags?: string[] | null; + ticket_number?: number | null; + username?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'contact_continent_fkey'; + columns: ['continent']; + referencedRelation: 'continent'; + referencedColumns: ['code']; + }, + { + foreignKeyName: 'contact_country_fkey'; + columns: ['country']; + referencedRelation: 'country'; + referencedColumns: ['code']; + }, + ]; + }; + contact_note: { + Row: { + contact_id: string; + created_at: string; + id: string; + text: string; + }; + Insert: { + contact_id: string; + created_at?: string; + id?: string; + text: string; + }; + Update: { + contact_id?: string; + created_at?: string; + id?: string; + text?: string; + }; + Relationships: [ + { + foreignKeyName: 'contact_note_contact_id_fkey'; + columns: ['contact_id']; + referencedRelation: 'contact'; + referencedColumns: ['id']; + }, + ]; + }; + continent: { + Row: { + code: string; + name: string | null; + }; + Insert: { + code: string; + name?: string | null; + }; + Update: { + code?: string; + name?: string | null; + }; + Relationships: []; + }; + country: { + Row: { + code: string; + continent_code: string; + full_name: string; + iso3: string; + name: string; + number: string; + }; + Insert: { + code: string; + continent_code: string; + full_name: string; + iso3: string; + name: string; + number: string; + }; + Update: { + code?: string; + continent_code?: string; + full_name?: string; + iso3?: string; + name?: string; + number?: string; + }; + Relationships: [ + { + foreignKeyName: 'country_continent_code_fkey'; + columns: ['continent_code']; + referencedRelation: 'continent'; + referencedColumns: ['code']; + }, + ]; + }; + multi_pk: { + Row: { + id_1: number; + id_2: number; + name: string | null; + }; + Insert: { + id_1: number; + id_2: number; + name?: string | null; + }; + Update: { + id_1?: number; + id_2?: number; + name?: string | null; + }; + Relationships: []; + }; + serial_key_table: { + Row: { + id: number; + value: string | null; + }; + Insert: { + id?: number; + value?: string | null; + }; + Update: { + id?: number; + value?: string | null; + }; + Relationships: []; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + has_low_ticket_number: { + Args: { + '': unknown; + }; + Returns: boolean; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; + storage: { + Tables: { + buckets: { + Row: { + allowed_mime_types: string[] | null; + avif_autodetection: boolean | null; + created_at: string | null; + file_size_limit: number | null; + id: string; + name: string; + owner: string | null; + public: boolean | null; + updated_at: string | null; + }; + Insert: { + allowed_mime_types?: string[] | null; + avif_autodetection?: boolean | null; + created_at?: string | null; + file_size_limit?: number | null; + id: string; + name: string; + owner?: string | null; + public?: boolean | null; + updated_at?: string | null; + }; + Update: { + allowed_mime_types?: string[] | null; + avif_autodetection?: boolean | null; + created_at?: string | null; + file_size_limit?: number | null; + id?: string; + name?: string; + owner?: string | null; + public?: boolean | null; + updated_at?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'buckets_owner_fkey'; + columns: ['owner']; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + ]; + }; + migrations: { + Row: { + executed_at: string | null; + hash: string; + id: number; + name: string; + }; + Insert: { + executed_at?: string | null; + hash: string; + id: number; + name: string; + }; + Update: { + executed_at?: string | null; + hash?: string; + id?: number; + name?: string; + }; + Relationships: []; + }; + objects: { + Row: { + bucket_id: string | null; + created_at: string | null; + id: string; + last_accessed_at: string | null; + metadata: Json | null; + name: string | null; + owner: string | null; + path_tokens: string[] | null; + updated_at: string | null; + version: string | null; + }; + Insert: { + bucket_id?: string | null; + created_at?: string | null; + id?: string; + last_accessed_at?: string | null; + metadata?: Json | null; + name?: string | null; + owner?: string | null; + path_tokens?: string[] | null; + updated_at?: string | null; + version?: string | null; + }; + Update: { + bucket_id?: string | null; + created_at?: string | null; + id?: string; + last_accessed_at?: string | null; + metadata?: Json | null; + name?: string | null; + owner?: string | null; + path_tokens?: string[] | null; + updated_at?: string | null; + version?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'objects_bucketId_fkey'; + columns: ['bucket_id']; + referencedRelation: 'buckets'; + referencedColumns: ['id']; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + can_insert_object: { + Args: { + bucketid: string; + name: string; + owner: string; + metadata: Json; + }; + Returns: undefined; + }; + extension: { + Args: { + name: string; + }; + Returns: string; + }; + filename: { + Args: { + name: string; + }; + Returns: string; + }; + foldername: { + Args: { + name: string; + }; + Returns: unknown; + }; + get_size_by_bucket: { + Args: Record; + Returns: { + size: number; + bucket_id: string; + }[]; + }; + search: { + Args: { + prefix: string; + bucketname: string; + limits?: number; + levels?: number; + offsets?: number; + search?: string; + sortcolumn?: string; + sortorder?: string; + }; + Returns: { + name: string; + id: string; + updated_at: string; + created_at: string; + last_accessed_at: string; + metadata: Json; + }[]; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +} diff --git a/packages/postgrest-server/tests/query-cache.test.ts b/packages/postgrest-server/tests/query-cache.test.ts new file mode 100644 index 000000000..fd6a81b01 --- /dev/null +++ b/packages/postgrest-server/tests/query-cache.test.ts @@ -0,0 +1,227 @@ +import { type SupabaseClient, createClient } from '@supabase/supabase-js'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DefaultStatefulContext, QueryCache } from '../src'; +import type { Database } from './database.types'; +import './utils'; +import { MemoryStore } from '../src/stores'; + +const TEST_PREFIX = 'postgrest-server-query'; + +const ctx = new DefaultStatefulContext(); + +describe('QueryCache', () => { + let client: SupabaseClient; + let provider: Map; + let testRunPrefix: string; + let contacts: Database['public']['Tables']['contact']['Row'][]; + + beforeAll(async () => { + testRunPrefix = `${TEST_PREFIX}-${Math.floor(Math.random() * 1000)}`; + client = createClient( + process.env.SUPABASE_URL as string, + process.env.SUPABASE_ANON_KEY as string, + ); + await client.from('contact').delete().ilike('username', `${TEST_PREFIX}%`); + + const { data } = await client + .from('contact') + .insert([ + { username: `${testRunPrefix}-username-1` }, + { username: `${testRunPrefix}-username-2` }, + { username: `${testRunPrefix}-username-3` }, + { username: `${testRunPrefix}-username-4` }, + ]) + .select('*') + .throwOnError(); + contacts = data ?? []; + expect(contacts).toHaveLength(4); + }); + beforeEach(() => { + provider = new Map(); + }); + + describe('.query()', () => { + it('should work for single', async () => { + const map = new Map(); + + const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + fresh: 1000, + stale: 2000, + }); + + const query = client + .from('contact') + .select('id,username') + .eq('username', contacts[0].username!) + .single(); + + const spy = vi.spyOn(query, 'then'); + + const res = await cache.query(query); + + const res2 = await cache.query(query); + + expect(res.data?.username).toEqual(contacts[0].username); + expect(res2.data?.username).toEqual(contacts[0].username); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should work for maybeSingle', async () => { + const map = new Map(); + + const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + fresh: 1000, + stale: 2000, + }); + + const query = client + .from('contact') + .select('id,username') + .eq('username', contacts[0].username!) + .maybeSingle(); + + const spy = vi.spyOn(query, 'then'); + + const res = await cache.query(query); + + const res2 = await cache.query(query); + + expect(res.data?.username).toEqual(contacts[0].username); + expect(res2.data?.username).toEqual(contacts[0].username); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should work for multiple', async () => { + const map = new Map(); + + const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + fresh: 1000, + stale: 2000, + }); + + const query = client + .from('contact') + .select('id,username', { count: 'exact' }) + .ilike('username', `${testRunPrefix}%`); + + const spy = vi.spyOn(query, 'then'); + + const res = await cache.query(query); + + const res2 = await cache.query(query); + + expect(res.count).toEqual(4); + expect(res2.count).toEqual(4); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('.swr()', () => { + it('should work for single', async () => { + const map = new Map(); + + const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + fresh: 1000, + stale: 2000, + }); + + const query = client + .from('contact') + .select('id,username') + .eq('username', contacts[0].username!) + .single(); + + const spy = vi.spyOn(query, 'then'); + + const res = await cache.swr(query); + + const res2 = await cache.swr(query); + + expect(res.data?.username).toEqual(contacts[0].username); + expect(res2.data?.username).toEqual(contacts[0].username); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should work for maybeSingle', async () => { + const map = new Map(); + + const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + fresh: 1000, + stale: 2000, + }); + + const query = client + .from('contact') + .select('id,username') + .eq('username', contacts[0].username!) + .maybeSingle(); + + const spy = vi.spyOn(query, 'then'); + + const res = await cache.swr(query); + + const res2 = await cache.swr(query); + + expect(res.data?.username).toEqual(contacts[0].username); + expect(res2.data?.username).toEqual(contacts[0].username); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should work for multiple', async () => { + const map = new Map(); + + const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + fresh: 1000, + stale: 2000, + }); + + const query = client + .from('contact') + .select('id,username', { count: 'exact' }) + .ilike('username', `${testRunPrefix}%`); + + const spy = vi.spyOn(query, 'then'); + + const res = await cache.swr(query); + + const res2 = await cache.swr(query); + + expect(res.count).toEqual(4); + expect(res2.count).toEqual(4); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + it('should dedupe', async () => { + const map = new Map(); + + const cache = new QueryCache(ctx, { + stores: [new MemoryStore({ persistentMap: map })], + fresh: 1000, + stale: 2000, + }); + + const query = client + .from('contact') + .select('id,username', { count: 'exact' }) + .ilike('username', `${testRunPrefix}%`); + + const spy = vi.spyOn(query, 'then'); + + const p1 = cache.query(query); + const p2 = cache.query(query); + + await Promise.all([p1, p2]); + + // TOOD figure out how we can test this from the outside + // i confirmed that this works via manual testing with logs + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/postgrest-server/tests/stores/memory.test.ts b/packages/postgrest-server/tests/stores/memory.test.ts new file mode 100644 index 000000000..069a89ee2 --- /dev/null +++ b/packages/postgrest-server/tests/stores/memory.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { MemoryStore, Value } from '../../src/stores'; + +const createCacheValue = (value: string): Value => ({ + data: value, + error: null, + count: 0, + status: 200, + statusText: 'OK', +}); + +describe('MemoryStore', () => { + let memoryStore: MemoryStore; + + beforeEach(() => { + memoryStore = new MemoryStore({ persistentMap: new Map() }); + }); + + test('should store value in the cache', async () => { + const key = 'key'; + const entry = { + value: createCacheValue('name'), + freshUntil: Date.now() + 1000000, + staleUntil: Date.now() + 100000000, + }; + await memoryStore.set(key, entry); + expect(await memoryStore.get(key)).toEqual(entry); + }); + + test('should return undefined if key does not exist in cache', async () => { + expect(await memoryStore.get('doesnotexist')).toEqual(undefined); + }); + + test('should remove value from cache', async () => { + memoryStore.set('key', { + value: createCacheValue('name'), + freshUntil: Date.now() + 10000000, + staleUntil: Date.now() + 12312412512515, + }); + memoryStore.remove('key'); + expect(await memoryStore.get('key')).toEqual(undefined); + }); +}); diff --git a/packages/postgrest-server/tests/swr-cache.test.ts b/packages/postgrest-server/tests/swr-cache.test.ts new file mode 100644 index 000000000..6289626ef --- /dev/null +++ b/packages/postgrest-server/tests/swr-cache.test.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'node:crypto'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { DefaultStatefulContext } from '../src/context'; +import { MemoryStore, Value } from '../src/stores'; +import { SwrCache } from '../src/swr-cache'; + +const fresh = 1000; +const stale = 2000; +const key = 'key'; +const value = randomUUID(); + +let cache: SwrCache; + +const createCacheValue = (value: string): Value => ({ + data: value, + error: null, + count: 0, + status: 200, + statusText: 'OK', +}); + +beforeEach(() => { + const memoryStore = new MemoryStore({ persistentMap: new Map() }); + cache = new SwrCache({ + ctx: new DefaultStatefulContext(), + store: memoryStore, + fresh, + stale, + }); +}); + +test('should store value in the cache', async () => { + await cache.set(key, createCacheValue(value)); + expect((await cache.get(key))?.data).toEqual(value); +}); + +test('should return undefined if key does not exist in cache', async () => { + expect(await cache.get('doesnotexist')).toEqual(undefined); +}); + +test('should remove value from cache', async () => { + await cache.set(key, createCacheValue(value)); + await cache.remove(key); + expect(await cache.get(key)).toEqual(undefined); +}); + +test('evicts outdated data', async () => { + await cache.set(key, createCacheValue(value)); + await new Promise((r) => setTimeout(r, 3000)); + const res = await cache.get(key); + expect(res).toEqual(undefined); +}); + +test('returns stale data', async () => { + await cache.set(key, createCacheValue(value)); + await new Promise((r) => setTimeout(r, 1500)); + const res = await cache.get(key); + expect(res?.data).toEqual(value); +}); + +describe('with fresh data', () => { + test('does not fetch from origin', async () => { + await cache.set(key, createCacheValue(value)); + await new Promise((r) => setTimeout(r, 500)); + + let fetchedFromOrigin = false; + const stale = await cache.swr(key, () => { + fetchedFromOrigin = true; + return Promise.resolve(createCacheValue('fresh_data')); + }); + expect(stale?.data).toEqual(value); + + await new Promise((r) => setTimeout(r, 500)); + const res = await cache.get(key); + expect(res?.data).toEqual(value); + expect(fetchedFromOrigin).toBe(false); + }); +}); + +describe('with stale data', () => { + test('fetches from origin', async () => { + await cache.set(key, createCacheValue(value)); + await new Promise((r) => setTimeout(r, 1500)); + const stale = await cache.swr(key, () => + Promise.resolve(createCacheValue('fresh_data')), + ); + expect(stale?.data).toEqual(value); + + await new Promise((r) => setTimeout(r, 1500)); + const res = await cache.get(key); + expect(res).toEqual(createCacheValue('fresh_data')); + }); +}); + +describe('with fresh=0', () => { + test('revalidates every time', async () => { + const memoryStore = new MemoryStore({ persistentMap: new Map() }); + const cache = new SwrCache({ + ctx: new DefaultStatefulContext(), + store: memoryStore, + fresh: 0, + stale: 800000, + }); + + let revalidated = 0; + for (let i = 0; i < 100; i++) { + const res = await cache.swr(key, async () => { + revalidated++; + return createCacheValue(i.toString()); + }); + if (i > 1) { + expect(Number(res?.data)).toEqual(i - 1); + } + } + + expect(revalidated).toBe(100); + }); +}); diff --git a/packages/postgrest-server/tests/utils.ts b/packages/postgrest-server/tests/utils.ts new file mode 100644 index 000000000..78d993a14 --- /dev/null +++ b/packages/postgrest-server/tests/utils.ts @@ -0,0 +1,4 @@ +import { resolve } from 'node:path'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: resolve(__dirname, '../../../.env.local') }); diff --git a/packages/postgrest-server/tsconfig.json b/packages/postgrest-server/tsconfig.json new file mode 100644 index 000000000..59bf36db1 --- /dev/null +++ b/packages/postgrest-server/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@supabase-cache-helpers/tsconfig/node.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/postgrest-server/tsup.config.ts b/packages/postgrest-server/tsup.config.ts new file mode 100644 index 000000000..a6f3e1c8d --- /dev/null +++ b/packages/postgrest-server/tsup.config.ts @@ -0,0 +1,18 @@ +import type { Options } from 'tsup'; + +export const tsup: Options = { + dts: true, + entry: { + index: 'src/index.ts', + stores: 'src/stores/index.ts', + }, + external: [/^@supabase\//], + format: ['cjs', 'esm'], + // inject: ['src/react-shim.js'], + // ! .cjs/.mjs doesn't work with Angular's webpack4 config by default! + legacyOutput: false, + sourcemap: true, + splitting: false, + bundle: true, + clean: true, +}; diff --git a/packages/postgrest-server/vitest.config.ts b/packages/postgrest-server/vitest.config.ts new file mode 100644 index 000000000..1c60dfe2a --- /dev/null +++ b/packages/postgrest-server/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + typecheck: { enabled: true }, + coverage: { + provider: 'istanbul', + }, + }, +}); diff --git a/packages/postgrest-swr/README.md b/packages/postgrest-swr/README.md index 9a17c9372..b44ad1d3e 100644 --- a/packages/postgrest-swr/README.md +++ b/packages/postgrest-swr/README.md @@ -5,16 +5,16 @@ A collection of SWR utilities for working with Supabase. Latest build GitHub Stars [![codecov](https://codecov.io/gh/psteinroe/supabase-cache-helpers/branch/main/graph/badge.svg?token=SPMWSVBRGX)](https://codecov.io/gh/psteinroe/supabase-cache-helpers) - ## Introduction -The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app/) and find out how it feels like for your users. +The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. It also provides a simple server-side abstraction to cache queries to the `PostgREST` API. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app) and find out how it feels like for your users. ## Features With just one single line of code, you can simplify the logic of **fetching, subscribing to updates, and mutating data as well as storage objects** in your project, and have all the amazing features of [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest) out-of-the-box. - **Seamless** integration with [SWR](https://swr.vercel.app) and [React Query](https://tanstack.com/query/latest) +- Support for **Server-Side** queries - **Automatic** cache key generation - Easy **Pagination** and **Infinite Scroll** queries - **Insert**, **update**, **upsert** and **delete** mutations @@ -27,3 +27,5 @@ And a lot [more](https://supabase-cache-helpers.vercel.app). --- **View full documentation and examples on [supabase-cache-helpers.vercel.app](https://supabase-cache-helpers.vercel.app).** + + diff --git a/packages/storage-react-query/README.md b/packages/storage-react-query/README.md index 6aee97531..ffa6c6489 100644 --- a/packages/storage-react-query/README.md +++ b/packages/storage-react-query/README.md @@ -5,16 +5,16 @@ A collection of React Query utilities for working with Supabase. Latest build GitHub Stars [![codecov](https://codecov.io/gh/psteinroe/supabase-cache-helpers/branch/main/graph/badge.svg?token=SPMWSVBRGX)](https://codecov.io/gh/psteinroe/supabase-cache-helpers) - ## Introduction -The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-react-query.vercel.app/) and find out how it feels like for your users. +The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. It also provides a simple server-side abstraction to cache queries to the `PostgREST` API. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app) and find out how it feels like for your users. ## Features With just one single line of code, you can simplify the logic of **fetching, subscribing to updates, and mutating data as well as storage objects** in your project, and have all the amazing features of [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest) out-of-the-box. - **Seamless** integration with [SWR](https://swr.vercel.app) and [React Query](https://tanstack.com/query/latest) +- Support for **Server-Side** queries - **Automatic** cache key generation - Easy **Pagination** and **Infinite Scroll** queries - **Insert**, **update**, **upsert** and **delete** mutations @@ -27,3 +27,6 @@ And a lot [more](https://supabase-cache-helpers.vercel.app). --- **View full documentation and examples on [supabase-cache-helpers.vercel.app](https://supabase-cache-helpers.vercel.app).** + + + diff --git a/packages/storage-swr/README.md b/packages/storage-swr/README.md index 67f4839ba..93aa60856 100644 --- a/packages/storage-swr/README.md +++ b/packages/storage-swr/README.md @@ -5,16 +5,16 @@ A collection of SWR utilities for working with Supabase. Latest build GitHub Stars [![codecov](https://codecov.io/gh/psteinroe/supabase-cache-helpers/branch/main/graph/badge.svg?token=SPMWSVBRGX)](https://codecov.io/gh/psteinroe/supabase-cache-helpers) - ## Introduction -The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app/) and find out how it feels like for your users. +The cache helpers bridge the gap between popular frontend cache management solutions such as [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest), and the Supabase client libraries. All features of [`postgrest-js`](https://github.com/supabase/postgrest-js), [`storage-js`](https://github.com/supabase/storage-js) and [`realtime-js`](https://github.com/supabase/realtime-js) are supported. It also provides a simple server-side abstraction to cache queries to the `PostgREST` API. The cache helpers parse any query into a unique and definite query key, and automatically populates your query cache with every mutation using implicit knowledge of the schema. Check out the [demo](https://supabase-cache-helpers-swr.vercel.app) and find out how it feels like for your users. ## Features With just one single line of code, you can simplify the logic of **fetching, subscribing to updates, and mutating data as well as storage objects** in your project, and have all the amazing features of [SWR](https://swr.vercel.app) or [React Query](https://tanstack.com/query/latest) out-of-the-box. - **Seamless** integration with [SWR](https://swr.vercel.app) and [React Query](https://tanstack.com/query/latest) +- Support for **Server-Side** queries - **Automatic** cache key generation - Easy **Pagination** and **Infinite Scroll** queries - **Insert**, **update**, **upsert** and **delete** mutations @@ -27,3 +27,7 @@ And a lot [more](https://supabase-cache-helpers.vercel.app). --- **View full documentation and examples on [supabase-cache-helpers.vercel.app](https://supabase-cache-helpers.vercel.app).** + + + + diff --git a/packages/tsconfig/node.json b/packages/tsconfig/node.json new file mode 100644 index 000000000..5509c105e --- /dev/null +++ b/packages/tsconfig/node.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "target": "ESNext", + "strict": true, + "esModuleInterop": false, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e81b07177..9992a87ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 14.2.0(react-dom@18.3.1)(react@18.3.1) nextra: specifier: 3.2.0 - version: 3.2.0(@types/react@18.3.3)(acorn@8.10.0)(next@14.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3) + version: 3.2.0(@types/react@18.3.3)(acorn@8.14.0)(next@14.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3) nextra-theme-docs: specifier: 3.2.0 version: 3.2.0(next@14.2.0)(nextra@3.2.0)(react-dom@18.3.1)(react@18.3.1) @@ -530,6 +530,40 @@ importers: specifier: 2.0.2 version: 2.0.2(happy-dom@15.0.0) + packages/postgrest-server: + dependencies: + '@supabase-cache-helpers/postgrest-core': + specifier: workspace:* + version: link:../postgrest-core + devDependencies: + '@supabase-cache-helpers/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@supabase/postgrest-js': + specifier: 1.16.1 + version: 1.16.1 + '@supabase/supabase-js': + specifier: 2.45.4 + version: 2.45.4 + '@vitest/coverage-istanbul': + specifier: ^2.0.2 + version: 2.0.2(vitest@2.0.2) + dotenv: + specifier: 16.4.0 + version: 16.4.0 + ioredis: + specifier: 5.4.1 + version: 5.4.1 + tsup: + specifier: 8.2.0 + version: 8.2.0(typescript@5.5.3) + typescript: + specifier: 5.5.3 + version: 5.5.3 + vitest: + specifier: 2.0.2 + version: 2.0.2(happy-dom@15.0.0) + packages/postgrest-swr: dependencies: '@supabase-cache-helpers/postgrest-core': @@ -776,7 +810,7 @@ packages: '@babel/traverse': 7.24.8 '@babel/types': 7.24.8 convert-source-map: 2.0.0 - debug: 4.3.5 + debug: 4.3.7 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -974,7 +1008,7 @@ packages: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.24.8 '@babel/types': 7.24.8 - debug: 4.3.5 + debug: 4.3.7 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -1826,6 +1860,10 @@ packages: - supports-color dev: false + /@ioredis/commands@1.2.0: + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: true + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1925,7 +1963,7 @@ packages: read-yaml-file: 1.1.0 dev: true - /@mdx-js/mdx@3.1.0(acorn@8.10.0): + /@mdx-js/mdx@3.1.0(acorn@8.14.0): resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} dependencies: '@types/estree': 1.0.5 @@ -1940,7 +1978,7 @@ packages: hast-util-to-jsx-runtime: 2.3.2 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 - recma-jsx: 1.0.0(acorn@8.10.0) + recma-jsx: 1.0.0(acorn@8.14.0) recma-stringify: 1.0.0 rehype-recma: 1.0.0 remark-mdx: 3.1.0 @@ -3983,7 +4021,7 @@ packages: peerDependencies: typescript: '*' dependencies: - debug: 4.3.5 + debug: 4.3.7 typescript: 5.6.3 transitivePeerDependencies: - supports-color @@ -4090,6 +4128,14 @@ packages: acorn: 8.10.0 dev: false + /acorn-jsx@5.3.2(acorn@8.14.0): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.14.0 + dev: false + /acorn@8.10.0: resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} engines: {node: '>=0.4.0'} @@ -4106,7 +4152,7 @@ packages: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} dependencies: - debug: 4.3.5 + debug: 4.3.7 transitivePeerDependencies: - supports-color dev: true @@ -4606,6 +4652,11 @@ packages: engines: {node: '>=6'} dev: false + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: true + /cmd-shim@6.0.2: resolution: {integrity: sha512-+FFYbB0YLaAkhkcrjkyNLYDiOsFSfRjwjY19LXk/psmMx1z00xlCv7hhQoTGXXIKi+YXHL/iiFo8NqMVQX9nOw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5091,6 +5142,7 @@ packages: optional: true dependencies: ms: 2.1.2 + dev: true /debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} @@ -5102,7 +5154,6 @@ packages: optional: true dependencies: ms: 2.1.3 - dev: false /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} @@ -5192,6 +5243,11 @@ packages: robust-predicates: 3.0.2 dev: false + /denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dev: true + /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -6173,7 +6229,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.3.5 + debug: 4.3.7 transitivePeerDependencies: - supports-color dev: true @@ -6270,6 +6326,23 @@ packages: loose-envify: 1.4.0 dev: false + /ioredis@5.4.1: + resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.7 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: true + /is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} dev: false @@ -6700,6 +6773,14 @@ packages: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: false + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: true + + /lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: true + /lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} dev: true @@ -7437,7 +7518,7 @@ packages: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} dependencies: '@types/debug': 4.1.7 - debug: 4.3.5 + debug: 4.3.7 decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.1 @@ -7552,10 +7633,10 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: false /mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -7707,14 +7788,14 @@ packages: flexsearch: 0.7.43 next: 14.2.0(react-dom@18.3.1)(react@18.3.1) next-themes: 0.3.0(react-dom@18.3.1)(react@18.3.1) - nextra: 3.2.0(@types/react@18.3.3)(acorn@8.10.0)(next@14.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3) + nextra: 3.2.0(@types/react@18.3.3)(acorn@8.14.0)(next@14.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.22.4 dev: false - /nextra@3.2.0(@types/react@18.3.3)(acorn@8.10.0)(next@14.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3): + /nextra@3.2.0(@types/react@18.3.3)(acorn@8.14.0)(next@14.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.6.3): resolution: {integrity: sha512-Gi+Q6BI9rFmQdy3e4FXCqgaSUcv8CGRVOVWbgY6/GTAWKMKK4v5M1gxYNhxdOywSkIoXrxSeXbIaj0qgNdjv3A==} engines: {node: '>=18'} peerDependencies: @@ -7724,7 +7805,7 @@ packages: dependencies: '@formatjs/intl-localematcher': 0.5.7 '@headlessui/react': 2.2.0(react-dom@18.3.1)(react@18.3.1) - '@mdx-js/mdx': 3.1.0(acorn@8.10.0) + '@mdx-js/mdx': 3.1.0(acorn@8.14.0) '@mdx-js/react': 3.1.0(@types/react@18.3.3)(react@18.3.1) '@napi-rs/simple-git': 0.1.9 '@shikijs/twoslash': 1.22.2(typescript@5.6.3) @@ -8503,10 +8584,10 @@ packages: vfile: 6.0.1 dev: false - /recma-jsx@1.0.0(acorn@8.10.0): + /recma-jsx@1.0.0(acorn@8.14.0): resolution: {integrity: sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q==} dependencies: - acorn-jsx: 5.3.2(acorn@8.10.0) + acorn-jsx: 5.3.2(acorn@8.14.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 @@ -8541,6 +8622,18 @@ packages: strip-indent: 3.0.0 dev: true + /redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + dev: true + + /redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: true + /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} dev: false @@ -9090,6 +9183,10 @@ packages: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true + /standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: true + /std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} dev: true @@ -9598,7 +9695,7 @@ packages: cac: 6.7.14 chokidar: 3.6.0 consola: 3.2.3 - debug: 4.3.5 + debug: 4.3.7 esbuild: 0.23.0 execa: 5.1.1 globby: 11.1.0 @@ -10027,7 +10124,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.5 + debug: 4.3.7 pathe: 1.1.2 tinyrainbow: 1.2.0 vite: 5.3.3 @@ -10110,7 +10207,7 @@ packages: '@vitest/spy': 2.0.2 '@vitest/utils': 2.0.2 chai: 5.1.1 - debug: 4.3.5 + debug: 4.3.7 execa: 8.0.1 happy-dom: 15.0.0 magic-string: 0.30.10