musora-content-services 2.185.0 → 2.186.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.
@@ -25,19 +25,20 @@ const publishedSongs = (brand: string) =>
25
25
  f.combine(f.type('song'), f.statusIn(['published']), f.defined('railcontent_id'), f.brand(brand))
26
26
 
27
27
  export async function fetchSongTitles(brand: string) {
28
- const result = await groq()
28
+ const result = await groq<Song[]>()
29
29
  .and(publishedSongs(brand))
30
30
  .order('published_on desc')
31
31
  .slice(0, 10)
32
32
  .select('_id', 'title', 'railcontent_id')
33
- .run<Song[]>()
33
+ .default([])
34
+ .run()
34
35
 
35
36
  return result.fold(
36
37
  (error) => {
37
38
  console.error(`${error.name}: ${error.message}`, error.groq)
38
39
  return []
39
40
  },
40
- (songs) => songs ?? []
41
+ (songs) => songs
41
42
  )
42
43
  }
43
44
 
@@ -105,27 +106,28 @@ interface Lesson {
105
106
  }
106
107
 
107
108
  export async function fetchLessonsWithoutPermissions(brand: string) {
108
- const result = await groq().and(f.brand(brand)).slice(0, 10).run<Lesson[]>()
109
+ const result = await groq<Lesson[]>().and(f.brand(brand)).slice(0, 10).default([]).run()
109
110
 
110
111
  return result
111
112
  .map((lessons) =>
112
- decorateAll<Lesson>(lessons ?? [], [pageTypeDecorator as FieldDecorator<Lesson>])
113
+ decorateAll<Lesson>(lessons, [pageTypeDecorator as FieldDecorator<Lesson>])
113
114
  )
114
115
  .recover([])
115
116
  }
116
117
 
117
118
  export async function fetchDecoratedLessons(brand: string) {
118
119
  const [result, permissions] = await Promise.all([
119
- groq()
120
+ groq<Lesson[]>()
120
121
  .and(f.combine(f.typeIn(['course']), f.brand(brand)))
121
122
  .slice(0, 10)
122
- .run<Lesson[]>(),
123
+ .default([])
124
+ .run(),
123
125
  fetchUserPermissions(),
124
126
  ])
125
127
 
126
128
  return result
127
129
  .map((lessons) =>
128
- decorateAll<Lesson>(lessons ?? [], [
130
+ decorateAll<Lesson>(lessons, [
129
131
  accessDecorator(permissions) as FieldDecorator<Lesson>,
130
132
  lifetimeUpgradeDecorator(permissions) as FieldDecorator<Lesson>,
131
133
  pageTypeDecorator as FieldDecorator<Lesson>,
@@ -135,18 +137,15 @@ export async function fetchDecoratedLessons(brand: string) {
135
137
  }
136
138
 
137
139
  export async function fetchLessonsWithNavigation(brand: string) {
138
- const [result, permissions] = await Promise.all([
139
- groq().and(f.brand(brand)).slice(0, 10).run<Lesson[]>(),
140
- fetchUserPermissions(),
141
- ])
140
+ const lessons = groq<Lesson[]>().and(f.brand(brand)).slice(0, 10).default([]).run()
141
+ const permissions = await fetchUserPermissions()
142
142
 
143
- const decorated = await result
143
+ return lessons
144
144
  .map((lessons) =>
145
- decorateAll<Lesson>(lessons ?? [], [accessDecorator(permissions) as FieldDecorator<Lesson>])
145
+ decorateAll<Lesson>(lessons, [accessDecorator(permissions) as FieldDecorator<Lesson>])
146
146
  )
147
147
  .mapAsync((lessons) => decorateNavigateTo(lessons) as Promise<Lesson[]>)
148
-
149
- return decorated.recover([])
148
+ .recover([])
150
149
  }
151
150
 
152
151
  export async function fetchWithACustomDecorator(brand: string) {
@@ -155,9 +154,9 @@ export async function fetchWithACustomDecorator(brand: string) {
155
154
  compute: (lesson) => lesson.permission_id === undefined,
156
155
  }
157
156
 
158
- const result = await groq().and(f.brand(brand)).run<Lesson[]>()
157
+ const result = await groq<Lesson[]>().and(f.brand(brand)).default([]).run()
159
158
 
160
- return result.map((lessons) => decorateAll<Lesson>(lessons ?? [], [isFree])).recover([])
159
+ return result.map((lessons) => decorateAll<Lesson>(lessons, [isFree])).recover([])
161
160
  }
162
161
 
163
162
  export async function fetchWhenPermissionsMayBeUnavailable(brand: string) {
@@ -167,7 +166,7 @@ export async function fetchWhenPermissionsMayBeUnavailable(brand: string) {
167
166
  )
168
167
 
169
168
  const [result, permissions] = await Promise.all([
170
- groq().and(f.brand(brand)).run<Lesson[]>(),
169
+ groq<Lesson[]>().and(f.brand(brand)).default([]).run(),
171
170
  fetchUserPermissions()
172
171
  .then(Either.right<SanityQueryError, UserPermissions>)
173
172
  .catch(queryError),
@@ -176,7 +175,7 @@ export async function fetchWhenPermissionsMayBeUnavailable(brand: string) {
176
175
  return result
177
176
  .flatMap((lessons) =>
178
177
  permissions.map((userPermissions) =>
179
- decorateAll<Lesson>(lessons ?? [], [
178
+ decorateAll<Lesson>(lessons, [
180
179
  accessDecorator(userPermissions) as FieldDecorator<Lesson>,
181
180
  ])
182
181
  )
@@ -188,17 +187,14 @@ export async function fetchWhenPermissionsMayBeUnavailable(brand: string) {
188
187
  }
189
188
 
190
189
  export async function decorateManuallyForPreciseTypes(brand: string) {
191
- const [result, permissions] = await Promise.all([
192
- groq().and(f.brand(brand)).slice(0, 10).run<Lesson[]>(),
193
- fetchUserPermissions(),
194
- ])
190
+ const lessons = groq<Lesson[]>().and(f.brand(brand)).slice(0, 10).default([]).run()
191
+ const permissions = await fetchUserPermissions()
195
192
 
196
- const decorated = await result
197
- .map((lessons) => decorateAccess(lessons ?? [], permissions))
193
+ return lessons
194
+ .map((lessons) => decorateAccess(lessons, permissions))
198
195
  .mapAsync((lessons) => decorateNavigateTo(lessons))
199
-
200
- return decorated.fold(
201
- () => [],
202
- (lessons) => lessons.map((lesson) => [lesson.need_access, lesson.navigateTo] as const)
203
- )
196
+ .fold(
197
+ () => [],
198
+ (lessons) => lessons.map((lesson) => [lesson.need_access, lesson.navigateTo] as const)
199
+ )
204
200
  }
@@ -123,6 +123,15 @@ export class Filters {
123
123
  return `slug.current == "${escapeDoubleQuoted(slug)}"`
124
124
  }
125
125
 
126
+ /**
127
+ * @param {string[]} slugs - Array of slugs to filter by
128
+ * @returns {string} Filter expression
129
+ * @example Filters.slugIn(['a', 'b']) // "slug.current in ['a','b']"
130
+ */
131
+ static slugIn(slugs: string[]): string {
132
+ return `slug.current in ${arrayToStringRepresentation(slugs)}`
133
+ }
134
+
126
135
  /**
127
136
  * @param {number} id - The railcontent_id to filter by
128
137
  * @returns {string} Filter expression
@@ -218,6 +227,59 @@ export class Filters {
218
227
  return `published_on >= "${escapeDoubleQuoted(date)}"`
219
228
  }
220
229
 
230
+ /**
231
+ * @param {string} field - The date field to compare
232
+ * @param {string} date - ISO date string
233
+ * @returns {string} Filter expression
234
+ * @example Filters.dateBefore('ends_at', '2026-01-01') // 'ends_at <= "2026-01-01"'
235
+ */
236
+ static dateBefore(field: string, date: string): string {
237
+ return `${field} <= "${escapeDoubleQuoted(date)}"`
238
+ }
239
+
240
+ /**
241
+ * @param {string} field - The date field to compare
242
+ * @param {string} date - ISO date string
243
+ * @returns {string} Filter expression
244
+ * @example Filters.dateAfter('starts_at', '2026-01-01') // 'starts_at >= "2026-01-01"'
245
+ */
246
+ static dateAfter(field: string, date: string): string {
247
+ return `${field} >= "${escapeDoubleQuoted(date)}"`
248
+ }
249
+
250
+ /**
251
+ * Matches documents whose window covers the given date.
252
+ *
253
+ * @param {string} startField - The field holding the start of the window
254
+ * @param {string} endField - The field holding the end of the window
255
+ * @param {string} date - ISO date string
256
+ * @returns {string} Filter expression
257
+ * @example Filters.dateWithin('starts_at', 'ends_at', '2026-11-25')
258
+ */
259
+ static dateWithin(startField: string, endField: string, date: string): string {
260
+ return Filters.combine(Filters.dateBefore(startField, date), Filters.dateAfter(endField, date))
261
+ }
262
+
263
+ /**
264
+ * @param {string} field - The boolean field to check
265
+ * @param {boolean} [value=true] - The value to match
266
+ * @returns {string} Filter expression
267
+ * @example Filters.flag('active') // "active == true"
268
+ */
269
+ static flag(field: string, value: boolean = true): string {
270
+ return `${field} == ${value}`
271
+ }
272
+
273
+ /**
274
+ * @param {string} field - The field to compare
275
+ * @param {string} value - The value to match
276
+ * @returns {string} Filter expression
277
+ * @example Filters.fieldEquals('key', 'upgrade-banner') // 'key == "upgrade-banner"'
278
+ */
279
+ static fieldEquals(field: string, value: string): string {
280
+ return `${field} == "${escapeDoubleQuoted(value)}"`
281
+ }
282
+
221
283
  /**
222
284
  * @param {string} field - The field to check
223
285
  * @returns {string} Filter expression
@@ -1,30 +1,54 @@
1
- import { Either } from '../ads/either'
1
+ import { AsyncEither } from '../ads/async-either'
2
2
  import { FieldAccess } from './field-access'
3
- import { composite, query, QueryBuilder } from './query'
3
+ import { composite, query, QueryBuilder, QueryBuilderState } from './query'
4
4
  import { QueryRunner, run, SanityQueryError } from './runner'
5
5
 
6
+ type ElementOf<T> = T extends (infer U)[] ? U : T
7
+
8
+ type Field<T> = (keyof ElementOf<T> & string) | (string & {})
9
+
6
10
  export interface RunnableQuery {
7
11
  build(): string
8
12
  toString(): string
9
- run<T>(runner?: QueryRunner<T>): Promise<Either<SanityQueryError, T | null>>
13
+ run<T>(runner?: QueryRunner<T>): AsyncEither<SanityQueryError, T | null>
10
14
  }
11
15
 
12
- export interface GroqBuilder extends QueryBuilder, RunnableQuery {
13
- selector(selector: string): GroqBuilder
14
- and(expr: string): GroqBuilder
15
- or(...exprs: string[]): GroqBuilder
16
- order(expr: string): GroqBuilder
17
- slice(offset: number, limit?: number): GroqBuilder
18
- first(): GroqBuilder
19
- select(...fields: string[]): GroqBuilder
20
- access(field: string, fieldAccess?: FieldAccess): GroqBuilder
21
- dereference(): GroqBuilder
22
- postFilter(expr: string): GroqBuilder
16
+ interface Chainable<T, Self> {
17
+ selector(selector: string): Self
18
+ and(expr: string): Self
19
+ or(...exprs: string[]): Self
20
+ order(expr: string): Self
21
+ slice(offset: number, limit?: number): Self
22
+ select(...fields: Field<T>[]): Self
23
+ access(field: Field<T>, fieldAccess?: FieldAccess): Self
24
+ dereference(): Self
25
+ postFilter(expr: string): Self
26
+ build(): string
27
+ toString(): string
28
+
29
+ _state(): QueryBuilderState
30
+ }
31
+
32
+ /**
33
+ * `Empty` is what `run` resolves to when the query matched nothing. {@link GroqBuilder.default}
34
+ * supplies a fallback and narrows it to `never`, which is the state a builder is assumed to be in
35
+ * unless it says otherwise: a builder that has not been given one is a `GroqBuilder<T, null>`.
36
+ */
37
+ export interface GroqBuilder<T = unknown, Empty = never> extends Chainable<T, GroqBuilder<T, Empty>> {
38
+ /**
39
+ * Never read. Methods are checked bivariantly, so without a property here a builder with no
40
+ * fallback would silently satisfy a signature promising one.
41
+ */
42
+ readonly __empty?: Empty
43
+
44
+ first(): GroqBuilder<ElementOf<T>, Empty>
45
+ default(value: T): GroqBuilder<T, never>
46
+ run<U = T>(runner?: QueryRunner<U>): AsyncEither<SanityQueryError, U | Empty>
23
47
  }
24
48
 
25
49
  /**
26
50
  * @param {string} selector
27
- * @returns {GroqBuilder}
51
+ * @returns {GroqBuilder<T>}
28
52
  * @example
29
53
  * const result = await groq()
30
54
  * .and(f.type('song'))
@@ -32,13 +56,30 @@ export interface GroqBuilder extends QueryBuilder, RunnableQuery {
32
56
  * .slice(0, 10)
33
57
  * .select('_id', 'title')
34
58
  * .run<Song[]>()
59
+ * @example
60
+ * // naming the type up front keeps it off every call in the chain
61
+ * const songs = await groq<Song[]>().and(f.type('song')).default([]).run()
35
62
  */
36
- const groqBuilder = (selector?: string): GroqBuilder => {
63
+ const groqBuilder = <T = unknown>(selector?: string): GroqBuilder<T, null> => {
37
64
  const builder = query(selector)
65
+ let fallback: { value: unknown } | undefined
66
+
67
+ const runnable = Object.assign(builder, {
68
+ default: (value: unknown) => {
69
+ fallback = { value }
70
+ return runnable
71
+ },
72
+ run: <U = T>(runner?: QueryRunner<U>) => {
73
+ const result = run<U>(builder.build(), runner)
74
+ const fallbackAtRunTime = fallback
75
+
76
+ return fallbackAtRunTime
77
+ ? result.map((value) => value ?? (fallbackAtRunTime.value as U))
78
+ : result
79
+ },
80
+ })
38
81
 
39
- return Object.assign(builder, {
40
- run: <T>(runner?: QueryRunner<T>) => run<T>(builder.build(), runner),
41
- }) as GroqBuilder
82
+ return runnable as unknown as GroqBuilder<T, null>
42
83
  }
43
84
 
44
85
  /**
@@ -1,3 +1,4 @@
1
+ import { AsyncEither } from '../ads/async-either'
1
2
  import { Either } from '../ads/either'
2
3
  import { SanityClient } from '../../infrastructure/sanity/SanityClient'
3
4
 
@@ -44,7 +45,7 @@ const defaultRunner = <T>(): QueryRunner<T> =>
44
45
  /**
45
46
  * @param {string} groq
46
47
  * @param {QueryRunner<T>} runner
47
- * @returns {Promise<Either<SanityQueryError, T | null>>}
48
+ * @returns {AsyncEither<SanityQueryError, T | null>}
48
49
  * @example
49
50
  * const result = await run<Song[]>(query().and(f.type('song')).build())
50
51
  * result.fold(
@@ -61,4 +62,4 @@ const defaultRunner = <T>(): QueryRunner<T> =>
61
62
  export const run = <T>(
62
63
  groq: string,
63
64
  runner: QueryRunner<T> = defaultRunner<T>()
64
- ): Promise<Either<SanityQueryError, T | null>> => runner(groq)
65
+ ): AsyncEither<SanityQueryError, T | null> => AsyncEither.of(runner(groq))
@@ -92,3 +92,36 @@ export type FaqDocument = {
92
92
  answer?: string
93
93
  }[]
94
94
  }
95
+
96
+ export type PromotionalCampaignDocument = {
97
+ _id: string
98
+ _type: "promotional-campaign"
99
+ _createdAt: string
100
+ _updatedAt: string
101
+ _rev: string
102
+ /** Campaign Name */
103
+ name: string
104
+ slug: { _type: "slug"; current: string }
105
+ /** Starts At */
106
+ starts_at: string
107
+ /** Ends At */
108
+ ends_at: string
109
+ /** Active? */
110
+ active?: boolean
111
+ /** Placements */
112
+ placements: {
113
+ _key: string
114
+ /** Placement Key */
115
+ key: string
116
+ title?: string
117
+ body?: string
118
+ /** Resolved to a CDN url by the promotions query projection. */
119
+ dark_mode_image?: string
120
+ /** Resolved to a CDN url by the promotions query projection. */
121
+ light_mode_image?: string
122
+ /** CTA Label */
123
+ cta_label?: string
124
+ /** Dismissible? */
125
+ dismissible?: boolean
126
+ }[]
127
+ }
@@ -37,12 +37,11 @@ export async function fetchArtists(
37
37
  ): Promise<Artists> {
38
38
  const type = f.type('artist')
39
39
  const postFilter = `lesson_count > 0`
40
- const { sort = 'lower(name)', offset = 0, limit = 20 } = options
40
+ const { sort = 'lower(name)', offset = 0, limit } = options
41
41
 
42
42
  const data = query()
43
43
  .and(type)
44
44
  .order(getSortOrder(sort, brand))
45
- .slice(offset, limit)
46
45
  .select(
47
46
  'name',
48
47
  `"slug": slug.current`,
@@ -51,6 +50,8 @@ export async function fetchArtists(
51
50
  )
52
51
  .postFilter(postFilter)
53
52
 
53
+ if (limit) data.slice(offset, limit)
54
+
54
55
  const q = `{
55
56
  "data": ${data},
56
57
  }`
@@ -140,7 +141,7 @@ export async function fetchArtistLessons(
140
141
  f.notDeprecated(),
141
142
  f.referencesIDWithFilter(f.combine(f.type('artist'), f.slug(slug))),
142
143
  f.brand(brand),
143
- f.permissions({showMembershipRestrictedContent: true}),
144
+ f.permissions({ showMembershipRestrictedContent: true }),
144
145
  f.searchMatch('title', searchTerm),
145
146
  f.includedFields(includedFields),
146
147
  f.progressIds(progressIds)
@@ -37,12 +37,11 @@ export async function fetchGenres(
37
37
  ): Promise<Genres> {
38
38
  const type = f.type('genre')
39
39
  const postFilter = `lesson_count > 0`
40
- const { sort = 'lower(name)', offset = 0, limit = 20 } = options
40
+ const { sort = 'lower(name)', offset = 0, limit } = options
41
41
 
42
42
  const data = query()
43
43
  .and(type)
44
44
  .order(getSortOrder(sort, brand))
45
- .slice(offset, limit)
46
45
  .select(
47
46
  'name',
48
47
  `"slug": slug.current`,
@@ -51,6 +50,8 @@ export async function fetchGenres(
51
50
  )
52
51
  .postFilter(postFilter)
53
52
 
53
+ if (limit) data.slice(offset, limit)
54
+
54
55
  const q = `{
55
56
  "data": ${data},
56
57
  }`
@@ -139,7 +140,7 @@ export async function fetchGenreLessons(
139
140
  f.notDeprecated(),
140
141
  f.referencesIDWithFilter(f.combine(f.type('genre'), f.slug(slug))),
141
142
  f.brand(brand),
142
- f.permissions({showMembershipRestrictedContent: true}),
143
+ f.permissions({ showMembershipRestrictedContent: true }),
143
144
  f.searchMatch('title', searchTerm),
144
145
  f.includedFields(includedFields),
145
146
  f.progressIds(progressIds)
@@ -38,12 +38,11 @@ export async function fetchInstructors(
38
38
  ): Promise<Instructors> {
39
39
  const type = f.type('instructor')
40
40
  const postFilter = `lesson_count > 0`
41
- const { sort = 'lower(name)', offset = 0, limit = 20 } = options
41
+ const { sort = 'lower(name)', offset = 0, limit } = options
42
42
 
43
43
  const data = query()
44
44
  .and(type)
45
45
  .order(getSortOrder(sort, brand))
46
- .slice(offset, limit)
47
46
  .select(
48
47
  'name',
49
48
  `"slug": slug.current`,
@@ -52,6 +51,8 @@ export async function fetchInstructors(
52
51
  )
53
52
  .postFilter(postFilter)
54
53
 
54
+ if (limit) data.slice(offset, limit)
55
+
55
56
  const q = `{
56
57
  "data": ${data},
57
58
  }`
@@ -129,7 +130,7 @@ export async function fetchInstructorLessons(
129
130
  offset = 0,
130
131
  limit = 20,
131
132
  includedFields = [],
132
- contentType = null
133
+ contentType = null,
133
134
  }: InstructorLessonsOptions = {}
134
135
  ): Promise<InstructorLessons> {
135
136
  sort = getSortOrder(sort, brand)
@@ -140,7 +141,7 @@ export async function fetchInstructorLessons(
140
141
  f.notDeprecated(),
141
142
  f.referencesIDWithFilter(f.combine(f.type('instructor'), f.slug(slug))),
142
143
  f.brand(brand),
143
- f.permissions({showMembershipRestrictedContent: true}),
144
+ f.permissions({ showMembershipRestrictedContent: true }),
144
145
  f.searchMatch('title', searchTerm),
145
146
  f.includedFields(includedFields)
146
147
  )