silgi 0.52.2 → 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,86 +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)`
33
+ *
34
+ * @example
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))
45
+ */
46
+ /**
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.
55
+ */
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.
19
67
  *
20
68
  * @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
- * ```
69
+ * const storage = createStorage({ driver: redisDriver({ ... }) })
70
+ * setCacheStorage(createUnstorageAdapter(storage))
37
71
  */
72
+ function createUnstorageAdapter(storage) {
73
+ return adaptUnstorage(storage);
74
+ }
38
75
  /**
39
- * Auto-connect ocache to silgi's storage.
40
- * Called lazily on first cacheQuery() use.
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).
41
83
  */
42
- let _storageConnected = false;
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) {
52
- storage.removeItem(key);
53
- return;
54
- }
55
- storage.setItem(key, value, opts?.ttl ? { ttl: opts.ttl } : void 0);
56
- }
57
- });
89
+ setStorage(adaptUnstorage(useStorage$1("cache")));
58
90
  } catch {}
59
91
  }
60
- /** 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
+ */
61
101
  const cacheKeyRegistry = /* @__PURE__ */ new Map();
62
102
  /**
63
- * 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.
64
115
  *
65
- * Uses ocache under the hood: TTL, SWR, dedup, integrity, bypass, invalidation.
66
- * Default: 60s TTL, SWR enabled.
116
+ * Defaults: 60-second TTL, SWR on. Every other knob comes from
117
+ * {@link CacheQueryOptions}.
67
118
  *
68
119
  * @example
69
- * ```ts
70
- * const listUsers = k
71
- * .$use(cacheQuery({ maxAge: 60 }))
72
- * .$resolve(({ ctx }) => ctx.db.users.findMany())
73
- *
74
- * // Advanced: bypass cache for admin, custom validation
75
- * const listPosts = k
76
- * .$use(cacheQuery({
77
- * maxAge: 300,
78
- * swr: true,
79
- * staleMaxAge: 600,
80
- * shouldBypassCache: (input) => (input as any)?.noCache,
81
- * shouldInvalidateCache: (input) => (input as any)?.refresh,
82
- * validate: (entry) => Array.isArray(entry.value),
83
- * onError: (err) => console.error('[cache]', err),
84
- * }))
85
- * .$resolve(({ ctx }) => ctx.db.posts.findMany())
86
- * ```
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())
87
129
  */
88
130
  function cacheQuery(options = {}) {
89
131
  const maxAge = options.maxAge ?? 60;
@@ -91,29 +133,32 @@ function cacheQuery(options = {}) {
91
133
  const staleMaxAge = options.staleMaxAge ?? maxAge;
92
134
  const customGetKey = options.getKey;
93
135
  let cacheName = options.name;
94
- const pendingNextMap = /* @__PURE__ */ new Map();
95
- let requestCounter = 0;
96
136
  let cachedFn = null;
97
- 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;
98
146
  return {
99
147
  kind: "wrap",
100
148
  fn: async (ctx, next) => {
101
149
  ensureStorageConnected();
102
150
  if (!cachedFn) {
103
- if (!cacheName) cacheName = ctx.__procedurePath || `proc_${hash(next.toString()).slice(0, 8)}`;
151
+ cacheName ??= ctx.__procedurePath ?? `proc_${hash(next.toString()).slice(0, 8)}`;
104
152
  const resolvedName = cacheName;
153
+ const resolvedBase = options.base ?? "/cache";
105
154
  const keySet = /* @__PURE__ */ new Set();
106
155
  cacheKeyRegistry.set(resolvedName, keySet);
107
- keyFn = customGetKey ? (input) => customGetKey(input) : (input) => input !== void 0 && input !== null ? hash(input) : "";
108
- const resolvedBase = options.base ?? "/cache";
109
156
  cachedFn = defineCachedFunction(async (_input, requestId) => {
110
- const mapKey = requestId ?? keyFn(_input);
111
- const nextFn = pendingNextMap.get(mapKey);
112
- if (nextFn) {
113
- pendingNextMap.delete(mapKey);
114
- return nextFn();
115
- }
116
- 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();
117
162
  }, {
118
163
  name: resolvedName,
119
164
  group: "silgi",
@@ -125,8 +170,8 @@ function cacheQuery(options = {}) {
125
170
  onError: options.onError,
126
171
  validate: options.validate,
127
172
  transform: options.transform,
128
- shouldBypassCache: options.shouldBypassCache ? (input) => options.shouldBypassCache(input) : void 0,
129
- shouldInvalidateCache: options.shouldInvalidateCache ? (input) => options.shouldInvalidateCache(input) : void 0,
173
+ shouldBypassCache: options.shouldBypassCache,
174
+ shouldInvalidateCache: options.shouldInvalidateCache,
130
175
  getKey: (input) => {
131
176
  const key = keyFn(input);
132
177
  keySet.add(`${resolvedBase}:silgi:${resolvedName}:${key}.json`);
@@ -136,77 +181,44 @@ function cacheQuery(options = {}) {
136
181
  }
137
182
  const input = ctx[RAW_INPUT];
138
183
  const requestId = `__req_${++requestCounter}`;
139
- pendingNextMap.set(requestId, next);
184
+ pendingNext.set(requestId, next);
140
185
  return cachedFn(input, requestId);
141
186
  }
142
187
  };
143
188
  }
144
189
  /**
145
- * Invalidate cached entries for a procedure by name.
190
+ * Wipe every cached entry for a procedure by its cache name.
146
191
  *
147
- * 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 })`.
148
195
  *
149
196
  * @example
150
- * ```ts
151
- * const createUser = k.$resolve(async ({ input, ctx }) => {
152
- * const user = await ctx.db.users.create(input)
153
- * await invalidateQueryCache('users_list')
154
- * return user
155
- * })
156
- * ```
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
+ * })
157
202
  */
158
203
  async function invalidateQueryCache(name) {
159
204
  const keys = cacheKeyRegistry.get(name);
160
- if (keys) {
161
- const storage = useStorage();
162
- await Promise.all([...keys].map((key) => storage.set(key, null)));
163
- keys.clear();
164
- }
205
+ if (!keys) return;
206
+ const storage = useStorage();
207
+ await Promise.all([...keys].map((key) => storage.set(key, null)));
208
+ keys.clear();
165
209
  }
166
210
  /**
167
- * Set the cache storage backend.
168
- *
169
- * Default: in-memory Map with TTL.
170
- * 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()`.
171
214
  *
172
215
  * @example
173
- * ```ts
174
- * import { setCacheStorage, createUnstorageAdapter } from 'silgi/cache'
175
- * import { createStorage } from 'silgi/unstorage'
176
- * import redisDriver from 'unstorage/drivers/redis'
177
- *
178
- * setCacheStorage(createUnstorageAdapter(
179
- * createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
180
- * ))
181
- * ```
216
+ * setCacheStorage(createUnstorageAdapter(
217
+ * createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
218
+ * ))
182
219
  */
183
220
  function setCacheStorage(storage) {
184
221
  setStorage(storage);
185
222
  }
186
- /**
187
- * Create an ocache-compatible storage adapter from an unstorage instance.
188
- *
189
- * @example
190
- * ```ts
191
- * import { createStorage } from 'silgi/unstorage'
192
- * import redisDriver from 'unstorage/drivers/redis'
193
- *
194
- * const storage = createStorage({ driver: redisDriver({ url: 'redis://localhost' }) })
195
- * const adapter = createUnstorageAdapter(storage)
196
- * setCacheStorage(adapter)
197
- * ```
198
- */
199
- function createUnstorageAdapter(storage) {
200
- return {
201
- get: (key) => storage.getItem(key),
202
- set: (key, value, opts) => {
203
- if (value === null || value === void 0) {
204
- storage.removeItem(key);
205
- return;
206
- }
207
- storage.setItem(key, value, opts);
208
- }
209
- };
210
- }
211
223
  //#endregion
212
224
  export { cacheQuery, createUnstorageAdapter, invalidateQueryCache, setCacheStorage };
@@ -13,8 +13,9 @@ import { WrapDef } from "../types.mjs";
13
13
  * - "" → undefined (empty strings become undefined)
14
14
  * - Everything else → kept as-is
15
15
  *
16
- * Implemented as a wrap (not a guard) so it runs after __rawInput is populated
17
- * by the pipeline compiler.
16
+ * Implemented as a wrap so it runs after the pipeline has populated the
17
+ * `RAW_INPUT` slot on ctx — see the caveat in the top-level docstring
18
+ * about ordering vs. input schema validation.
18
19
  */
19
20
  declare const coerceGuard: WrapDef<Record<string, unknown>>;
20
21
  declare function coerceValue(value: unknown): unknown;
@@ -7,17 +7,33 @@ import { RAW_INPUT } from "../core/ctx-symbols.mjs";
7
7
  * query parameters. This wrap coerces common types automatically:
8
8
  * "123" → 123, "true" → true, "null" → null, etc.
9
9
  *
10
+ * @remarks
11
+ * **Ordering caveat.** The pipeline validates `$input` schemas *before*
12
+ * running the wrap onion. That means a plain `z.number()` rejects "123"
13
+ * before this wrap can see it. You have three options:
14
+ *
15
+ * 1. **Use Zod's own coercion** — `z.coerce.number()`, `z.coerce.boolean()`.
16
+ * Zero extra wrap, works for simple cases.
17
+ * 2. **Skip the input schema and validate inside the resolver** — pairs
18
+ * naturally with `coerceGuard` because the wrap always runs first.
19
+ * 3. **Use a string-accepting schema and coerce with `.transform()`** —
20
+ * e.g. `z.object({ id: z.string().transform(Number) })`. Again no
21
+ * wrap needed.
22
+ *
23
+ * `coerceGuard` itself is useful when you have NO input schema but still
24
+ * want `"42"` / `"true"` / `"null"` normalised before your resolver runs.
25
+ *
10
26
  * @example
11
27
  * ```ts
12
- * import { coerceGuard } from "silgi/plugins"
13
- *
28
+ * // Works: no schema wrap can reshape freely.
14
29
  * const getUser = k
15
30
  * .$use(coerceGuard)
16
- * .$input(z.object({ id: z.number(), active: z.boolean().optional() }))
17
- * .$resolve(({ input }) => db.users.find(input.id))
31
+ * .$resolve(({ input }) => db.users.find((input as any).id))
18
32
  *
19
- * // GET /users/get?data={"id":"42","active":"true"}
20
- * // input is coerced to { id: 42, active: true }
33
+ * // Works: schema uses z.coerce.
34
+ * const getUser2 = k
35
+ * .$input(z.object({ id: z.coerce.number() }))
36
+ * .$resolve(({ input }) => db.users.find(input.id))
21
37
  * ```
22
38
  */
23
39
  /**
@@ -32,8 +48,9 @@ import { RAW_INPUT } from "../core/ctx-symbols.mjs";
32
48
  * - "" → undefined (empty strings become undefined)
33
49
  * - Everything else → kept as-is
34
50
  *
35
- * Implemented as a wrap (not a guard) so it runs after __rawInput is populated
36
- * by the pipeline compiler.
51
+ * Implemented as a wrap so it runs after the pipeline has populated the
52
+ * `RAW_INPUT` slot on ctx — see the caveat in the top-level docstring
53
+ * about ordering vs. input schema validation.
37
54
  */
38
55
  const coerceGuard = {
39
56
  kind: "wrap",
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 };