silgi 0.53.0 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,83 +4,128 @@ import { defineCachedFunction, setStorage, useStorage } from "ocache";
4
4
  import { hash } from "ohash";
5
5
  //#region src/plugins/cache.ts
6
6
  /**
7
- * Cache plugin — production-grade response caching powered by ocache.
8
- *
9
- * All ocache features exposed:
10
- * - TTL + Stale-While-Revalidate (SWR)
11
- * - Request deduplication (concurrent calls share one in-flight promise)
12
- * - Automatic integrity (redeploy invalidates stale cache)
13
- * - shouldBypassCache / shouldInvalidateCache callbacks
14
- * - Entry validation and transformation
15
- * - Multi-tier storage (read cascade, write to all)
16
- * - Pluggable storage via `setCacheStorage()` (default: in-memory)
17
- * - unstorage adapter for Redis, Cloudflare KV, S3, etc.
18
- * - Mutation-triggered invalidation
7
+ * Response cache plugin
8
+ * -----------------------
9
+ *
10
+ * Caches resolver output using `ocache` underneath. Drop the wrap on
11
+ * a query procedure and its return value is memoized, deduplicated,
12
+ * and (by default) served stale-while-revalidate.
13
+ *
14
+ * The storage backend is pluggable. If the parent silgi instance has
15
+ * a `'cache'` mount configured via `silgi({ storage })`, we wire ocache
16
+ * to that mount the first time `cacheQuery()` runs. Otherwise ocache
17
+ * falls back to its built-in in-memory map. Users who want a different
18
+ * backend without the silgi storage mount can call `setCacheStorage()`
19
+ * directly, or wrap an unstorage instance via `createUnstorageAdapter()`.
20
+ *
21
+ * Every ocache feature is exposed through the options:
22
+ *
23
+ * TTL + stale-while-revalidate — `maxAge`, `swr`, `staleMaxAge`
24
+ * Request deduplication — built-in, concurrent calls share
25
+ * one in-flight promise
26
+ * Integrity — redeploy invalidates stale cache
27
+ * when the resolver's hash changes
28
+ * Per-request bypass / invalidate — `shouldBypassCache`,
29
+ * `shouldInvalidateCache`
30
+ * Entry validation / transform — `validate`, `transform`
31
+ * Multi-tier storage — read cascade, write to all
32
+ * Manual invalidation — `invalidateQueryCache(name)`
19
33
  *
20
34
  * @example
21
- * ```ts
22
- * import { cacheQuery, setCacheStorage } from 'silgi/cache'
23
- *
24
- * // Basic: cache for 60 seconds with SWR
25
- * const listUsers = k
26
- * .$use(cacheQuery({ maxAge: 60 }))
27
- * .$resolve(({ ctx }) => ctx.db.users.findMany())
28
- *
29
- * // With unstorage backend (Redis)
30
- * import { createUnstorageAdapter } from 'silgi/cache'
31
- * import { createStorage } from 'silgi/unstorage'
32
- * import redisDriver from 'unstorage/drivers/redis'
33
- *
34
- * const storage = createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
35
- * setCacheStorage(createUnstorageAdapter(storage))
36
- * ```
35
+ * import { cacheQuery, setCacheStorage, createUnstorageAdapter } from 'silgi/cache'
36
+ * import { createStorage } from 'silgi/unstorage'
37
+ * import redisDriver from 'unstorage/drivers/redis'
38
+ *
39
+ * const listUsers = k
40
+ * .$use(cacheQuery({ maxAge: 60 }))
41
+ * .$resolve(({ ctx }) => ctx.db.users.findMany())
42
+ *
43
+ * const storage = createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
44
+ * setCacheStorage(createUnstorageAdapter(storage))
37
45
  */
38
46
  /**
39
- * Auto-connect ocache to silgi's storage.
40
- * Called lazily on first cacheQuery() use.
47
+ * Build an ocache `StorageInterface` from anything that quacks like
48
+ * an unstorage store. The plugin uses it twice — once internally to
49
+ * bridge silgi's configured storage mount, and once as a public helper
50
+ * for users who bring their own unstorage instance.
51
+ *
52
+ * Setting `value` to `null` / `undefined` intentionally triggers a
53
+ * delete so ocache's "mark as stale" signalling survives every
54
+ * backend uniformly.
41
55
  */
42
- let _storageConnected = false;
56
+ function adaptUnstorage(storage) {
57
+ return {
58
+ get: (key) => storage.getItem(key),
59
+ set: (key, value, opts) => {
60
+ if (value === null || value === void 0) return storage.removeItem(key);
61
+ return storage.setItem(key, value, opts);
62
+ }
63
+ };
64
+ }
65
+ /**
66
+ * Public helper for users who already have an unstorage instance.
67
+ *
68
+ * @example
69
+ * const storage = createStorage({ driver: redisDriver({ ... }) })
70
+ * setCacheStorage(createUnstorageAdapter(storage))
71
+ */
72
+ function createUnstorageAdapter(storage) {
73
+ return adaptUnstorage(storage);
74
+ }
75
+ /**
76
+ * Connect ocache to the silgi instance's `'cache'` storage mount, if
77
+ * one is configured. Idempotent — the first `cacheQuery()` call wins
78
+ * and every subsequent one is a cheap boolean check.
79
+ *
80
+ * When silgi has no storage mount the call is a silent no-op: ocache
81
+ * keeps its built-in in-memory backend, which is still correct (just
82
+ * not shared across processes).
83
+ */
84
+ let storageConnected = false;
43
85
  function ensureStorageConnected() {
44
- if (_storageConnected) return;
45
- _storageConnected = true;
86
+ if (storageConnected) return;
87
+ storageConnected = true;
46
88
  try {
47
- const storage = useStorage$1("cache");
48
- setStorage({
49
- get: (key) => storage.getItem(key),
50
- set: (key, value, opts) => {
51
- if (value === null || value === void 0) return storage.removeItem(key);
52
- return storage.setItem(key, value, opts?.ttl ? { ttl: opts.ttl } : void 0);
53
- }
54
- });
89
+ setStorage(adaptUnstorage(useStorage$1("cache")));
55
90
  } catch {}
56
91
  }
57
- /** Registry of cached function keys for invalidation */
92
+ /**
93
+ * Per-procedure set of cache keys, keyed by the procedure's cache name.
94
+ * `invalidateQueryCache(name)` uses this to wipe every key that a given
95
+ * procedure has written to backing storage.
96
+ *
97
+ * Module-global by design: different silgi instances in the same
98
+ * process calling the same procedure name end up sharing the same
99
+ * ocache backend anyway, so a unified registry is correct.
100
+ */
58
101
  const cacheKeyRegistry = /* @__PURE__ */ new Map();
59
102
  /**
60
- * Wrap middleware that caches query results.
103
+ * Build the cache-key generator from user options. When the user
104
+ * passes `getKey`, we use it verbatim. Otherwise we hash the input
105
+ * with ohash, falling back to an empty string when there is no input
106
+ * (shared-cache-for-parameterless-queries is almost always what
107
+ * people want).
108
+ */
109
+ function buildKeyFn(custom) {
110
+ if (custom) return custom;
111
+ return (input) => input !== void 0 && input !== null ? hash(input) : "";
112
+ }
113
+ /**
114
+ * Wrap middleware that caches a query procedure's output.
61
115
  *
62
- * Uses ocache under the hood: TTL, SWR, dedup, integrity, bypass, invalidation.
63
- * Default: 60s TTL, SWR enabled.
116
+ * Defaults: 60-second TTL, SWR on. Every other knob comes from
117
+ * {@link CacheQueryOptions}.
64
118
  *
65
119
  * @example
66
- * ```ts
67
- * const listUsers = k
68
- * .$use(cacheQuery({ maxAge: 60 }))
69
- * .$resolve(({ ctx }) => ctx.db.users.findMany())
70
- *
71
- * // Advanced: bypass cache for admin, custom validation
72
- * const listPosts = k
73
- * .$use(cacheQuery({
74
- * maxAge: 300,
75
- * swr: true,
76
- * staleMaxAge: 600,
77
- * shouldBypassCache: (input) => (input as any)?.noCache,
78
- * shouldInvalidateCache: (input) => (input as any)?.refresh,
79
- * validate: (entry) => Array.isArray(entry.value),
80
- * onError: (err) => console.error('[cache]', err),
81
- * }))
82
- * .$resolve(({ ctx }) => ctx.db.posts.findMany())
83
- * ```
120
+ * const listPosts = k
121
+ * .$use(cacheQuery({
122
+ * maxAge: 300,
123
+ * staleMaxAge: 600,
124
+ * shouldBypassCache: (input) => (input as any)?.noCache,
125
+ * validate: (entry) => Array.isArray(entry.value),
126
+ * onError: (err) => console.error('[cache]', err),
127
+ * }))
128
+ * .$resolve(({ ctx }) => ctx.db.posts.findMany())
84
129
  */
85
130
  function cacheQuery(options = {}) {
86
131
  const maxAge = options.maxAge ?? 60;
@@ -88,29 +133,32 @@ function cacheQuery(options = {}) {
88
133
  const staleMaxAge = options.staleMaxAge ?? maxAge;
89
134
  const customGetKey = options.getKey;
90
135
  let cacheName = options.name;
91
- const pendingNextMap = /* @__PURE__ */ new Map();
92
- let requestCounter = 0;
93
136
  let cachedFn = null;
94
- let keyFn;
137
+ let keyFn = buildKeyFn(customGetKey);
138
+ /**
139
+ * Two concurrent requests can race through the same cache entry:
140
+ * ocache calls our inner `_resolve` for each one, and they must not
141
+ * share a closure-scoped `next`. We keep a per-request map keyed
142
+ * by a monotonic counter so each call resolves its *own* `next()`.
143
+ */
144
+ const pendingNext = /* @__PURE__ */ new Map();
145
+ let requestCounter = 0;
95
146
  return {
96
147
  kind: "wrap",
97
148
  fn: async (ctx, next) => {
98
149
  ensureStorageConnected();
99
150
  if (!cachedFn) {
100
- if (!cacheName) cacheName = ctx.__procedurePath || `proc_${hash(next.toString()).slice(0, 8)}`;
151
+ cacheName ??= ctx.__procedurePath ?? `proc_${hash(next.toString()).slice(0, 8)}`;
101
152
  const resolvedName = cacheName;
153
+ const resolvedBase = options.base ?? "/cache";
102
154
  const keySet = /* @__PURE__ */ new Set();
103
155
  cacheKeyRegistry.set(resolvedName, keySet);
104
- keyFn = customGetKey ? (input) => customGetKey(input) : (input) => input !== void 0 && input !== null ? hash(input) : "";
105
- const resolvedBase = options.base ?? "/cache";
106
156
  cachedFn = defineCachedFunction(async (_input, requestId) => {
107
- const mapKey = requestId ?? keyFn(_input);
108
- const nextFn = pendingNextMap.get(mapKey);
109
- if (nextFn) {
110
- pendingNextMap.delete(mapKey);
111
- return nextFn();
112
- }
113
- throw new Error("[silgi/cache] Missing next() for cache resolve");
157
+ const key = requestId ?? keyFn(_input);
158
+ const fn = pendingNext.get(key);
159
+ if (!fn) throw new Error("[silgi/cache] Missing next() for cache resolve");
160
+ pendingNext.delete(key);
161
+ return fn();
114
162
  }, {
115
163
  name: resolvedName,
116
164
  group: "silgi",
@@ -122,8 +170,8 @@ function cacheQuery(options = {}) {
122
170
  onError: options.onError,
123
171
  validate: options.validate,
124
172
  transform: options.transform,
125
- shouldBypassCache: options.shouldBypassCache ? (input) => options.shouldBypassCache(input) : void 0,
126
- shouldInvalidateCache: options.shouldInvalidateCache ? (input) => options.shouldInvalidateCache(input) : void 0,
173
+ shouldBypassCache: options.shouldBypassCache,
174
+ shouldInvalidateCache: options.shouldInvalidateCache,
127
175
  getKey: (input) => {
128
176
  const key = keyFn(input);
129
177
  keySet.add(`${resolvedBase}:silgi:${resolvedName}:${key}.json`);
@@ -133,74 +181,44 @@ function cacheQuery(options = {}) {
133
181
  }
134
182
  const input = ctx[RAW_INPUT];
135
183
  const requestId = `__req_${++requestCounter}`;
136
- pendingNextMap.set(requestId, next);
184
+ pendingNext.set(requestId, next);
137
185
  return cachedFn(input, requestId);
138
186
  }
139
187
  };
140
188
  }
141
189
  /**
142
- * Invalidate cached entries for a procedure by name.
190
+ * Wipe every cached entry for a procedure by its cache name.
143
191
  *
144
- * Call this after mutations to clear related query caches.
192
+ * Typically called after a mutation that makes related queries stale.
193
+ * Names default to the procedure's auto-generated path, or whatever
194
+ * was passed via `cacheQuery({ name })`.
145
195
  *
146
196
  * @example
147
- * ```ts
148
- * const createUser = k.$resolve(async ({ input, ctx }) => {
149
- * const user = await ctx.db.users.create(input)
150
- * await invalidateQueryCache('users_list')
151
- * return user
152
- * })
153
- * ```
197
+ * const createUser = k.$resolve(async ({ input, ctx }) => {
198
+ * const user = await ctx.db.users.create(input)
199
+ * await invalidateQueryCache('users_list')
200
+ * return user
201
+ * })
154
202
  */
155
203
  async function invalidateQueryCache(name) {
156
204
  const keys = cacheKeyRegistry.get(name);
157
- if (keys) {
158
- const storage = useStorage();
159
- await Promise.all([...keys].map((key) => storage.set(key, null)));
160
- keys.clear();
161
- }
205
+ if (!keys) return;
206
+ const storage = useStorage();
207
+ await Promise.all([...keys].map((key) => storage.set(key, null)));
208
+ keys.clear();
162
209
  }
163
210
  /**
164
- * Set the cache storage backend.
165
- *
166
- * Default: in-memory Map with TTL.
167
- * For production, use `createUnstorageAdapter()` with Redis, Cloudflare KV, etc.
211
+ * Replace ocache's storage backend. Default is an in-memory map with
212
+ * TTL; production deployments usually want Redis or Cloudflare KV via
213
+ * `createUnstorageAdapter()`.
168
214
  *
169
215
  * @example
170
- * ```ts
171
- * import { setCacheStorage, createUnstorageAdapter } from 'silgi/cache'
172
- * import { createStorage } from 'silgi/unstorage'
173
- * import redisDriver from 'unstorage/drivers/redis'
174
- *
175
- * setCacheStorage(createUnstorageAdapter(
176
- * createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
177
- * ))
178
- * ```
216
+ * setCacheStorage(createUnstorageAdapter(
217
+ * createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
218
+ * ))
179
219
  */
180
220
  function setCacheStorage(storage) {
181
221
  setStorage(storage);
182
222
  }
183
- /**
184
- * Create an ocache-compatible storage adapter from an unstorage instance.
185
- *
186
- * @example
187
- * ```ts
188
- * import { createStorage } from 'silgi/unstorage'
189
- * import redisDriver from 'unstorage/drivers/redis'
190
- *
191
- * const storage = createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
192
- * const adapter = createUnstorageAdapter(storage)
193
- * setCacheStorage(adapter)
194
- * ```
195
- */
196
- function createUnstorageAdapter(storage) {
197
- return {
198
- get: (key) => storage.getItem(key),
199
- set: (key, value, opts) => {
200
- if (value === null || value === void 0) return storage.removeItem(key);
201
- return storage.setItem(key, value, opts);
202
- }
203
- };
204
- }
205
223
  //#endregion
206
224
  export { cacheQuery, createUnstorageAdapter, invalidateQueryCache, setCacheStorage };
package/dist/scalar.d.mts CHANGED
@@ -9,42 +9,53 @@ interface ScalarOptions {
9
9
  url: string;
10
10
  description?: string;
11
11
  }[];
12
- /** Security scheme (e.g. Bearer token) */
12
+ /** Security scheme (e.g. Bearer token, API key header). */
13
13
  security?: {
14
- type: 'http' | 'apiKey';
15
- scheme?: string;
16
- bearerFormat?: string;
17
- in?: 'header' | 'query';
14
+ type: 'http' | 'apiKey'; /** For `type: 'http'`, e.g. `'bearer'`. */
15
+ scheme?: string; /** For `type: 'http'` + bearer, e.g. `'JWT'`. */
16
+ bearerFormat?: string; /** For `type: 'apiKey'`, which side of the request carries the key. */
17
+ in?: 'header' | 'query'; /** For `type: 'apiKey'`, the header / query-param name. */
18
18
  name?: string;
19
19
  description?: string;
20
20
  };
21
- /** Contact info */
22
21
  contact?: {
23
22
  name?: string;
24
23
  url?: string;
25
24
  email?: string;
26
25
  };
27
- /** License */
28
26
  license?: {
29
27
  name: string;
30
28
  url?: string;
31
29
  };
32
- /** External docs */
33
30
  externalDocs?: {
34
31
  url: string;
35
32
  description?: string;
36
33
  };
37
34
  /**
38
- * Scalar UI script source.
35
+ * Where to load the Scalar UI script from.
39
36
  *
40
- * - `'cdn'` (default) loads from cdn.jsdelivr.net
41
- * - `'unpkg'` — loads from unpkg.com
42
- * - `'local'` — serves from node_modules (offline, requires `@scalar/api-reference` installed)
43
- * - Custom URL string — self-hosted or local path (e.g. `'/assets/scalar.js'`)
37
+ * `'cdn'` `cdn.jsdelivr.net` (default).
38
+ * `'unpkg'` — `unpkg.com`.
39
+ * `'local'` — serve from `node_modules` (offline; requires the
40
+ * `@scalar/api-reference` package installed).
41
+ * string — any custom URL, e.g. a self-hosted asset path.
44
42
  */
45
43
  cdn?: 'cdn' | 'unpkg' | 'local' | (string & {});
46
44
  }
45
+ /**
46
+ * Generate an OpenAPI 3.1.0 document for a `RouterDef`.
47
+ *
48
+ * The document is a plain object and can be re-serialized, cached,
49
+ * piped into any OpenAPI consumer (codegen, docs site, validators). We
50
+ * do not return a typed `OpenAPIV3_1.Document` because the typings in
51
+ * the ecosystem are noisy; downstream consumers usually only care
52
+ * about a handful of keys anyway.
53
+ */
47
54
  declare function generateOpenAPI(router: RouterDef, options?: ScalarOptions, basePath?: string, registry?: SchemaRegistry): Record<string, unknown>;
55
+ /**
56
+ * Render the minimal HTML shell the Scalar UI needs. The UI itself is
57
+ * a single script that reads the `data-url` attribute to pull the spec.
58
+ */
48
59
  declare function scalarHTML(specUrl: string, options?: ScalarOptions): string;
49
60
  //#endregion
50
61
  export { ScalarOptions, generateOpenAPI, scalarHTML };