@sanity/sdk 2.5.0 → 2.7.0

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.
Files changed (46) hide show
  1. package/dist/index.d.ts +429 -27
  2. package/dist/index.js +657 -266
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/_exports/index.ts +18 -3
  6. package/src/auth/authMode.test.ts +56 -0
  7. package/src/auth/authMode.ts +71 -0
  8. package/src/auth/authStore.test.ts +85 -4
  9. package/src/auth/authStore.ts +63 -125
  10. package/src/auth/authStrategy.ts +39 -0
  11. package/src/auth/dashboardAuth.ts +132 -0
  12. package/src/auth/standaloneAuth.ts +109 -0
  13. package/src/auth/studioAuth.ts +217 -0
  14. package/src/auth/studioModeAuth.test.ts +43 -1
  15. package/src/auth/studioModeAuth.ts +10 -1
  16. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +21 -6
  17. package/src/client/clientStore.test.ts +45 -43
  18. package/src/client/clientStore.ts +23 -9
  19. package/src/config/loggingConfig.ts +149 -0
  20. package/src/config/sanityConfig.ts +82 -22
  21. package/src/projection/getProjectionState.ts +6 -5
  22. package/src/projection/projectionQuery.test.ts +38 -55
  23. package/src/projection/projectionQuery.ts +27 -31
  24. package/src/projection/projectionStore.test.ts +4 -4
  25. package/src/projection/projectionStore.ts +3 -2
  26. package/src/projection/resolveProjection.ts +2 -2
  27. package/src/projection/statusQuery.test.ts +35 -0
  28. package/src/projection/statusQuery.ts +71 -0
  29. package/src/projection/subscribeToStateAndFetchBatches.test.ts +63 -50
  30. package/src/projection/subscribeToStateAndFetchBatches.ts +106 -27
  31. package/src/projection/types.ts +12 -0
  32. package/src/projection/util.ts +0 -1
  33. package/src/query/queryStore.test.ts +64 -0
  34. package/src/query/queryStore.ts +33 -11
  35. package/src/releases/getPerspectiveState.test.ts +17 -14
  36. package/src/releases/getPerspectiveState.ts +58 -38
  37. package/src/releases/releasesStore.test.ts +59 -61
  38. package/src/releases/releasesStore.ts +21 -35
  39. package/src/releases/utils/isReleasePerspective.ts +7 -0
  40. package/src/store/createActionBinder.test.ts +211 -1
  41. package/src/store/createActionBinder.ts +102 -13
  42. package/src/store/createSanityInstance.test.ts +85 -1
  43. package/src/store/createSanityInstance.ts +55 -4
  44. package/src/utils/logger-usage-example.md +141 -0
  45. package/src/utils/logger.test.ts +757 -0
  46. package/src/utils/logger.ts +537 -0
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Logging infrastructure for the Sanity SDK
3
+ *
4
+ * Provides multi-level, namespace-based logging for both SDK users and maintainers.
5
+ * In production builds, all logging can be stripped via tree-shaking.
6
+ *
7
+ * @example SDK User
8
+ * ```ts
9
+ * import {configureLogging} from '@sanity/sdk'
10
+ *
11
+ * configureLogging({
12
+ * level: 'info',
13
+ * namespaces: ['auth', 'document']
14
+ * })
15
+ * ```
16
+ *
17
+ * @example SDK Maintainer
18
+ * ```ts
19
+ * configureLogging({
20
+ * level: 'trace',
21
+ * namespaces: ['*'],
22
+ * internal: true
23
+ * })
24
+ * ```
25
+ */
26
+
27
+ /**
28
+ * Log levels in order of verbosity (least to most)
29
+ * - error: Critical failures that prevent operation
30
+ * - warn: Issues that may cause problems but don't stop execution
31
+ * - info: High-level informational messages (SDK user level)
32
+ * - debug: Detailed debugging information (maintainer level)
33
+ * - trace: Very detailed tracing (maintainer level, includes RxJS streams)
34
+ * @public
35
+ */
36
+ export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'
37
+
38
+ /**
39
+ * Log namespaces organize logs by functional domain
40
+ *
41
+ * @remarks
42
+ * This is an extensible string type. As logging is added to more modules,
43
+ * additional namespaces will be recognized. Currently implemented namespaces
44
+ * will be documented as they are added.
45
+ * @internal
46
+ */
47
+ export type LogNamespace = string
48
+
49
+ /**
50
+ * Configuration for the logging system
51
+ * @public
52
+ */
53
+ export interface LoggerConfig {
54
+ /**
55
+ * Minimum log level to output
56
+ * @defaultValue 'warn'
57
+ */
58
+ level?: LogLevel
59
+
60
+ /**
61
+ * Namespaces to enable. Use ['*'] for all namespaces
62
+ * @defaultValue []
63
+ * @remarks
64
+ * Available namespaces depend on which modules have logging integrated.
65
+ * Check the SDK documentation for the current list of instrumented modules.
66
+ * @example ['auth', 'document']
67
+ */
68
+ namespaces?: string[]
69
+
70
+ /**
71
+ * Enable internal/maintainer-level logging
72
+ * Shows RxJS streams, store internals, etc.
73
+ * @defaultValue false
74
+ */
75
+ internal?: boolean
76
+
77
+ /**
78
+ * Custom log handler (for testing or custom output)
79
+ * @defaultValue console methods
80
+ */
81
+ handler?: LogHandler
82
+
83
+ /**
84
+ * Enable timestamps in log output
85
+ * @defaultValue true
86
+ */
87
+ timestamps?: boolean
88
+
89
+ /**
90
+ * Enable in production builds
91
+ * @defaultValue false
92
+ */
93
+ enableInProduction?: boolean
94
+ }
95
+
96
+ /**
97
+ * Custom log handler interface
98
+ *
99
+ * @internal
100
+ */
101
+ export interface LogHandler {
102
+ error: (message: string, context?: LogContext) => void
103
+ warn: (message: string, context?: LogContext) => void
104
+ info: (message: string, context?: LogContext) => void
105
+ debug: (message: string, context?: LogContext) => void
106
+ trace: (message: string, context?: LogContext) => void
107
+ }
108
+
109
+ /**
110
+ * Context object attached to log messages
111
+ *
112
+ * This interface allows you to attach arbitrary contextual data to log messages.
113
+ * The index signature `[key: string]: unknown` enables you to add any custom
114
+ * properties relevant to your log entry (e.g., `userId`, `documentId`, `action`, etc.).
115
+ *
116
+ * **Sensitive data sanitization:**
117
+ * Top-level keys containing sensitive names (`token`, `password`, `secret`, `apiKey`,
118
+ * `authorization`) are automatically redacted to `[REDACTED]` in log output.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * logger.info('User logged in', {
123
+ * userId: '123', // Custom context
124
+ * action: 'login', // Custom context
125
+ * token: 'secret' // Will be redacted to [REDACTED]
126
+ * })
127
+ * ```
128
+ *
129
+ * @internal
130
+ */
131
+ export interface LogContext {
132
+ /**
133
+ * Custom context properties that provide additional information about the log entry.
134
+ * Any key-value pairs can be added here (e.g., userId, documentId, requestId, etc.).
135
+ * Keys with sensitive names (token, password, secret, apiKey, authorization) are
136
+ * automatically sanitized.
137
+ */
138
+ [key: string]: unknown
139
+ /** Error object if logging an error */
140
+ error?: Error | unknown
141
+ /** Duration in milliseconds for timed operations */
142
+ duration?: number
143
+ /** Stack trace for debugging */
144
+ stack?: string
145
+ /** Instance context (automatically added when available) */
146
+ instanceContext?: InstanceContext
147
+ }
148
+
149
+ /**
150
+ * Instance context information automatically added to logs
151
+ * @internal
152
+ */
153
+ export interface InstanceContext {
154
+ /** Unique instance ID */
155
+ instanceId?: string
156
+ /** Project ID */
157
+ projectId?: string
158
+ /** Dataset name */
159
+ dataset?: string
160
+ }
161
+
162
+ /**
163
+ * Logger instance for a specific namespace
164
+ * @internal
165
+ */
166
+ export interface Logger {
167
+ readonly namespace: string
168
+ error: (message: string, context?: LogContext) => void
169
+ warn: (message: string, context?: LogContext) => void
170
+ info: (message: string, context?: LogContext) => void
171
+ debug: (message: string, context?: LogContext) => void
172
+ trace: (message: string, context?: LogContext) => void
173
+ /** Check if a log level is enabled (for performance-sensitive code) */
174
+ isLevelEnabled: (level: LogLevel) => boolean
175
+ /** Create a child logger with extended context */
176
+ child: (context: LogContext) => Logger
177
+ /** Get the instance context if available */
178
+ getInstanceContext: () => InstanceContext | undefined
179
+ }
180
+
181
+ // Log level priority (lower number = higher priority)
182
+ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
183
+ error: 0,
184
+ warn: 1,
185
+ info: 2,
186
+ debug: 3,
187
+ trace: 4,
188
+ }
189
+
190
+ // Default logging configuration
191
+ const DEFAULT_CONFIG: Required<LoggerConfig> = {
192
+ level: 'warn',
193
+ namespaces: [],
194
+ internal: false,
195
+ timestamps: true,
196
+ enableInProduction: false,
197
+ handler: {
198
+ // eslint-disable-next-line no-console
199
+ error: console.error.bind(console),
200
+ // eslint-disable-next-line no-console
201
+ warn: console.warn.bind(console),
202
+ // eslint-disable-next-line no-console
203
+ info: console.info.bind(console),
204
+ // eslint-disable-next-line no-console
205
+ debug: console.debug.bind(console),
206
+ // eslint-disable-next-line no-console
207
+ trace: console.debug.bind(console), // trace uses console.debug
208
+ },
209
+ }
210
+
211
+ /**
212
+ * Parse DEBUG environment variable for automatic logging configuration
213
+ *
214
+ * Supports patterns similar to Sanity CLI/Studio:
215
+ * - DEBUG=sanity:* (all namespaces, debug level)
216
+ * - DEBUG=sanity:auth,sanity:document (specific namespaces)
217
+ * - DEBUG=sanity:trace:* (all namespaces, trace level)
218
+ * - DEBUG=sanity:*:internal (enable internal logs)
219
+ *
220
+ * @internal
221
+ */
222
+ export function parseDebugEnvVar(): Partial<LoggerConfig> | null {
223
+ if (typeof process === 'undefined' || !process.env?.['DEBUG']) {
224
+ return null
225
+ }
226
+
227
+ const debug = process.env['DEBUG']
228
+
229
+ // Only process if it includes 'sanity'
230
+ if (!debug.includes('sanity')) {
231
+ return null
232
+ }
233
+
234
+ const config: Partial<LoggerConfig> = {}
235
+
236
+ // Parse level from pattern like "sanity:trace:*" or "sanity:debug:*"
237
+ const levelMatch = debug.match(/sanity:(trace|debug|info|warn|error):/)
238
+ const hasLevelSpecifier = !!levelMatch
239
+ if (levelMatch) {
240
+ config.level = levelMatch[1] as LogLevel
241
+ } else {
242
+ // Default to debug level if just "sanity:*" or "sanity:namespace"
243
+ config.level = 'debug'
244
+ }
245
+
246
+ // Parse namespaces
247
+ if (debug === 'sanity') {
248
+ config.namespaces = ['*']
249
+ } else if (hasLevelSpecifier && debug.match(/sanity:(trace|debug|info|warn|error):\*/)) {
250
+ // Pattern like "sanity:trace:*" - wildcard after level
251
+ config.namespaces = ['*']
252
+ } else if (!hasLevelSpecifier && debug.includes('sanity:*')) {
253
+ // Pattern like "sanity:*" - wildcard without level
254
+ config.namespaces = ['*']
255
+ } else {
256
+ // Extract specific namespaces like "sanity:auth,sanity:document"
257
+ const namespaces = debug
258
+ .split(',')
259
+ .filter((s) => s.includes('sanity:'))
260
+ .map((s) => {
261
+ // Remove 'sanity:' prefix
262
+ const cleaned = s.replace(/^sanity:/, '')
263
+ // If there's a level specifier, skip it
264
+ if (hasLevelSpecifier && cleaned.match(/^(trace|debug|info|warn|error):/)) {
265
+ // Get the part after the level: "trace:auth" -> "auth"
266
+ return cleaned.split(':').slice(1).join(':')
267
+ }
268
+ // Otherwise get the first part: "auth:something" -> "auth"
269
+ return cleaned.split(':')[0]
270
+ })
271
+ .filter(Boolean)
272
+ .filter((ns) => ns !== '*') // Filter out wildcards
273
+
274
+ if (namespaces.length > 0) {
275
+ config.namespaces = namespaces
276
+ }
277
+ }
278
+
279
+ // Check for internal flag: DEBUG=sanity:*:internal
280
+ if (debug.includes(':internal')) {
281
+ config.internal = true
282
+ }
283
+
284
+ return config
285
+ }
286
+
287
+ // Global configuration - initialized with env var settings if present
288
+ const envConfig = parseDebugEnvVar()
289
+ let globalConfig: Required<LoggerConfig> = {
290
+ ...DEFAULT_CONFIG,
291
+ ...(envConfig ?? {}),
292
+ }
293
+
294
+ // Log that env var configuration was detected (only if DEBUG is set)
295
+ // Note: This runs at module initialization, difficult to test without complex module mocking
296
+ /* c8 ignore next 14 */
297
+ if (envConfig) {
298
+ const shouldLog =
299
+ ['info', 'debug', 'trace'].includes(globalConfig.level) || globalConfig.level === 'warn'
300
+ if (shouldLog) {
301
+ // eslint-disable-next-line no-console
302
+ console.info(
303
+ `[${new Date().toISOString()}] [INFO] [sdk] Logging auto-configured from DEBUG environment variable`,
304
+ {
305
+ level: globalConfig.level,
306
+ namespaces: globalConfig.namespaces,
307
+ internal: globalConfig.internal,
308
+ source: 'env:DEBUG',
309
+ value: typeof process !== 'undefined' ? process.env?.['DEBUG'] : undefined,
310
+ },
311
+ )
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Configure the global logging system
317
+ * @public
318
+ */
319
+ export function configureLogging(config: LoggerConfig): void {
320
+ globalConfig = {
321
+ ...globalConfig,
322
+ ...config,
323
+ handler: config.handler ?? globalConfig.handler,
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Get the current logging configuration
329
+ * @internal
330
+ */
331
+ export function getLoggingConfig(): Readonly<Required<LoggerConfig>> {
332
+ return globalConfig
333
+ }
334
+
335
+ /**
336
+ * Reset logging to default configuration
337
+ * @internal
338
+ */
339
+ export function resetLogging(): void {
340
+ globalConfig = {...DEFAULT_CONFIG}
341
+ }
342
+
343
+ /**
344
+ * Check if logging is enabled in the current environment
345
+ * @internal
346
+ */
347
+ function isLoggingEnabled(): boolean {
348
+ // In production, only log if explicitly enabled
349
+ if (typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'production') {
350
+ return globalConfig.enableInProduction
351
+ }
352
+ return true
353
+ }
354
+
355
+ /**
356
+ * Check if a namespace is enabled
357
+ * @internal
358
+ */
359
+ function isNamespaceEnabled(namespace: string): boolean {
360
+ if (!isLoggingEnabled()) return false
361
+ if (globalConfig.namespaces.includes('*')) return true
362
+ return globalConfig.namespaces.includes(namespace)
363
+ }
364
+
365
+ /**
366
+ * Check if a log level is enabled
367
+ * @internal
368
+ */
369
+ function isLevelEnabled(level: LogLevel): boolean {
370
+ if (!isLoggingEnabled()) return false
371
+ return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[globalConfig.level]
372
+ }
373
+
374
+ /**
375
+ * Format a log message with timestamp, namespace, and instance context
376
+ * @internal
377
+ */
378
+ function formatMessage(
379
+ namespace: LogNamespace,
380
+ level: LogLevel,
381
+ message: string,
382
+ context?: LogContext,
383
+ ): [string, LogContext | undefined] {
384
+ const parts: string[] = []
385
+
386
+ if (globalConfig.timestamps) {
387
+ const timestamp = new Date().toISOString()
388
+ parts.push(`[${timestamp}]`)
389
+ }
390
+
391
+ parts.push(`[${level.toUpperCase()}]`)
392
+ parts.push(`[${namespace}]`)
393
+
394
+ // Add instance context if available
395
+ const instanceContext = context?.instanceContext
396
+ if (instanceContext) {
397
+ if (instanceContext.projectId) {
398
+ parts.push(`[project:${instanceContext.projectId}]`)
399
+ }
400
+ if (instanceContext.dataset) {
401
+ parts.push(`[dataset:${instanceContext.dataset}]`)
402
+ }
403
+ if (instanceContext.instanceId) {
404
+ parts.push(`[instance:${instanceContext.instanceId.slice(0, 8)}]`)
405
+ }
406
+ }
407
+
408
+ parts.push(message)
409
+
410
+ return [parts.join(' '), context]
411
+ }
412
+
413
+ /**
414
+ * Sanitize context for logging (remove sensitive data)
415
+ * @internal
416
+ * @remarks
417
+ * This performs shallow sanitization only - it redacts top-level keys that contain
418
+ * sensitive names (token, password, secret, apiKey, authorization).
419
+ * Nested sensitive data (e.g., `\{auth: \{token: 'secret'\}\}`) will NOT be redacted.
420
+ * If deep sanitization is needed in the future, this can be enhanced to recursively
421
+ * traverse nested objects.
422
+ */
423
+ function sanitizeContext(context?: LogContext): LogContext | undefined {
424
+ if (!context || Object.keys(context).length === 0) return undefined
425
+
426
+ const sanitized = {...context}
427
+
428
+ // Remove or redact sensitive fields at the top level
429
+ const sensitiveKeys = ['token', 'password', 'secret', 'apiKey', 'authorization']
430
+ for (const key of Object.keys(sanitized)) {
431
+ if (sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive))) {
432
+ sanitized[key] = '[REDACTED]'
433
+ }
434
+ }
435
+
436
+ return sanitized
437
+ }
438
+
439
+ /**
440
+ * Create a logger for a specific namespace
441
+ * @param namespace - A string identifier for the logging domain (e.g., 'auth', 'document')
442
+ * @param baseContext - Optional base context to include in all log messages
443
+ * @remarks
444
+ * If baseContext includes an `instanceContext` property (with projectId, dataset, instanceId),
445
+ * it will be automatically formatted into the log output for easier debugging of
446
+ * multi-instance scenarios.
447
+ * @public
448
+ */
449
+ export function createLogger(namespace: string, baseContext?: LogContext): Logger {
450
+ const logAtLevel = (level: LogLevel, message: string, context?: LogContext) => {
451
+ // Early return if namespace or level not enabled
452
+ if (!isNamespaceEnabled(namespace)) return
453
+ if (!isLevelEnabled(level)) return
454
+
455
+ // Skip internal logs if not enabled
456
+ if (context?.['internal'] && !globalConfig.internal) return
457
+
458
+ const mergedContext = {...baseContext, ...context}
459
+ const sanitized = sanitizeContext(mergedContext)
460
+ const [formatted, finalContext] = formatMessage(namespace, level, message, sanitized)
461
+
462
+ globalConfig.handler[level](formatted, finalContext)
463
+ }
464
+
465
+ const logger: Logger = {
466
+ namespace,
467
+ error: (message, context) => logAtLevel('error', message, context),
468
+ warn: (message, context) => logAtLevel('warn', message, context),
469
+ info: (message, context) => logAtLevel('info', message, context),
470
+ debug: (message, context) => logAtLevel('debug', message, context),
471
+ trace: (message, context) => logAtLevel('trace', message, {...context, internal: true}),
472
+ isLevelEnabled: (level) => isNamespaceEnabled(namespace) && isLevelEnabled(level),
473
+ child: (childContext) => createLogger(namespace, {...baseContext, ...childContext}),
474
+ getInstanceContext: () => baseContext?.instanceContext,
475
+ }
476
+
477
+ return logger
478
+ }
479
+
480
+ /**
481
+ * Create a performance timer for measuring operation duration
482
+ * @internal
483
+ */
484
+ export function createTimer(
485
+ namespace: string,
486
+ operation: string,
487
+ ): {end: (message?: string, context?: LogContext) => number} {
488
+ const logger = createLogger(namespace)
489
+ const start = performance.now()
490
+
491
+ return {
492
+ end: (message?: string, context?: LogContext): number => {
493
+ const duration = performance.now() - start
494
+ logger.debug(message ?? `${operation} completed`, {
495
+ ...context,
496
+ operation,
497
+ duration,
498
+ })
499
+ return duration
500
+ },
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Utility to log RxJS operator execution (for maintainers)
506
+ * Will be exported in future PR when logging is added to stores
507
+ * @internal
508
+ */
509
+ /* c8 ignore next 4 */
510
+ function logRxJSOperator(namespace: string, operator: string, context?: LogContext): void {
511
+ const logger = createLogger(namespace)
512
+ logger.trace(`RxJS: ${operator}`, {...context, internal: true, operator})
513
+ }
514
+
515
+ /**
516
+ * Extract instance context from a SanityInstance for logging
517
+ * Will be exported in future PR when logging is added to stores
518
+ * @param instance - The SanityInstance to extract context from
519
+ * @returns Instance context suitable for logging
520
+ * @internal
521
+ */
522
+ /* c8 ignore next 7 */
523
+ function getInstanceContext(instance: {
524
+ instanceId?: string
525
+ config?: {projectId?: string; dataset?: string}
526
+ }): InstanceContext {
527
+ return {
528
+ instanceId: instance.instanceId,
529
+ projectId: instance.config?.projectId,
530
+ dataset: instance.config?.dataset,
531
+ }
532
+ }
533
+
534
+ // Prevent unused function warnings - these will be exported and used in future PRs
535
+ /* c8 ignore next 2 */
536
+ void logRxJSOperator
537
+ void getInstanceContext