musora-content-services 2.185.0 → 2.185.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.
@@ -0,0 +1,60 @@
1
+ ---
2
+ date: 2026-10-01
3
+ branch: chore/AGI-remove-limit-from-queries
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/1067
5
+ status: open
6
+ tags: [[chore]]
7
+ related: [[2026-07-20-query-builder-tostring]]
8
+ ---
9
+
10
+ # Optional limit in fetchArtists, fetchGenres and fetchInstructors
11
+
12
+ ## Context
13
+ `fetchArtists`, `fetchGenres` and `fetchInstructors` destructured their options with a hard-coded `limit = 20`. Callers that wanted the complete list of artists, genres or instructors had no way to express that — the only workaround was to pass an arbitrarily large number. The requirement was to make `limit` genuinely optional, with omission meaning "return everything".
14
+
15
+ ## Decision
16
+ Removed the `limit = 20` default from all three services and made the `.slice()` call conditional:
17
+
18
+ ```ts
19
+ const { sort = 'lower(name)', offset = 0, limit } = options
20
+
21
+ const data = query()
22
+ .and(type)
23
+ .order(getSortOrder(sort, brand))
24
+ .select(...)
25
+ .postFilter(postFilter)
26
+
27
+ if (limit) data.slice(offset, limit)
28
+ ```
29
+
30
+ Simply dropping the default was not sufficient and would have shipped a silent breaking change. `QueryBuilder.slice(offset, limit)` in `src/lib/sanity/query.ts` computes its expression as:
31
+
32
+ ```ts
33
+ const sliceExpr = !limit ? `[${offset}]` : `[${offset}...${offset + limit}]`
34
+ ```
35
+
36
+ A missing `limit` therefore produces `[offset]` — a single-index GROQ slice, the same branch `first()` depends on — not an unbounded one. The response shape would have flipped from `{ data: Artist[] }` to `{ data: Artist }`, and TypeScript would not have caught it because the declared return type stays `Artists`.
37
+
38
+ Skipping the `.slice()` call instead leaves `state.slice` at its monoid identity (`''`), so `build()` emits no slice expression and GROQ returns the full array.
39
+
40
+ The guard is placed after the builder chain rather than inside it. This is safe for two reasons, both already covered by `test/unit/lib/query.test.ts`: every builder method returns the same closure instance and mutates shared state (`returns same builder instance for chaining`, `builds valid query after multiple modifications`), and `build()` renders state in a fixed order regardless of call order, so the slice still lands last in the emitted query.
41
+
42
+ ## Alternatives Considered
43
+ **Fixing `slice()` itself** to treat a nullish `limit` as "no slice" is the more correct fix — `!limit` is the wrong test, since `limit: 0` also falls through to the single-index branch. Rejected for now because `first()` is implemented as `this.slice()` and relies on that exact branch, making it a wider change than this ticket warranted.
44
+
45
+ **Honouring `offset` without a `limit`** via a sentinel ceiling (`data.slice(offset, MAX_RESULTS)`). Rejected: GROQ has no open-ended slice syntax (`[5...]` is invalid), so any such value would be arbitrary, and no current caller paginates without a limit.
46
+
47
+ **Adding a `when(condition, apply)` combinator** to `QueryBuilder` to keep the chain fluent. Rejected as new public API for a single call site. An audit of the other conditional clauses in these queries showed none would adopt it: `searchMatch`, `includedFields` and `progressIds` already return `Filters.empty` when their input is absent, and the `and`/`or` monoids drop empty strings, so conditionality is handled at the filter layer rather than the builder layer.
48
+
49
+ ## Process Notes
50
+ The `f.brand(brand)` helper is the one filter that does not self-nullify, which is why four sites spell out `brand ? f.brand(brand) : f.empty` (`counts.ts:26`, `:33`, `:58` and `filter.ts:481`). Making it return `Filters.empty` on a falsy brand would remove those ternaries and match the surrounding idiom, but it was deliberately left alone: an absent brand filter does not return nothing, it returns every brand's content. The required `brand: string` signature currently blocks that mistake at compile time, and the explicit ternary keeps "all brands" a conscious decision at the call site.
51
+
52
+ The post-chain guard only works because builders are interpolated lazily. `data` is a `QueryBuilder`, not a string; the `q` template literal coerces it via the `toString()` added in [[2026-07-20-query-builder-tostring]], which runs below the guard. Had `build()` been called eagerly at the end of the chain, the conditional slice would have been dropped.
53
+
54
+ ## Consequences
55
+ - Omitting `limit` now returns the complete list instead of the first 20 rows.
56
+ - `limit: 0` previously produced a single-object response through the faulty `!limit` branch; it now returns the full list.
57
+ - An `offset` passed without a `limit` is ignored rather than approximated.
58
+ - No caller changes required. Both existing consumers pass an explicit limit: `MusoraApp/src_v2/components/screens/CollectionList.tsx` uses `limit: 50`, and `musora-platform-frontend/src/modules/agi/services/entity.service.ts` defaults `fetchAll` to 40.
59
+ - Unbounded queries are now expressible against Sanity, so a caller omitting `limit` on a large brand will fetch every matching document. Worth watching if a new consumer adopts it on a hot path.
60
+ - Left unaddressed: the `Artists`, `Genres` and `Instructors` interfaces declare `total: number`, but the query only projects `"data"`. `total` is always `undefined`, so the pagination meta in `entity.service.ts` never populates.
@@ -1,14 +1,4 @@
1
1
  {
2
- "permissions": {
3
- "allow": [
4
- "Bash(rg:*)",
5
- "Bash(npm run lint:*)",
6
- "Bash(ls:*)",
7
- "Bash(npm test *)",
8
- "Bash(npx jest *)"
9
- ],
10
- "deny": []
11
- },
12
2
  "enabledMcpjsonServers": [
13
3
  "1password",
14
4
  "atlassian",
@@ -19,16 +9,16 @@
19
9
  "hex",
20
10
  "sanity",
21
11
  "mysql",
12
+ "mysql-staging",
13
+ "mysql-local",
22
14
  "slack",
23
15
  "langfuse",
24
16
  "chrome-devtools",
17
+ "mobile",
25
18
  "railway",
26
19
  "github",
27
20
  "asana",
28
21
  "nightwatch",
29
- "mysql-staging",
30
- "mysql-local",
31
- "mobile",
32
22
  "sentry",
33
23
  "customerio"
34
24
  ]
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.185.1](https://github.com/railroadmedia/musora-content-services/compare/v2.185.0...v2.185.1) (2026-10-06)
6
+
5
7
  ## [2.185.0](https://github.com/railroadmedia/musora-content-services/compare/v2.184.1...v2.185.0) (2026-10-01)
6
8
 
7
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.185.0",
3
+ "version": "2.185.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -434,6 +434,7 @@ import {
434
434
  confirmEmailChange,
435
435
  createPendingAccount,
436
436
  deleteAccount,
437
+ maskEmail,
437
438
  numberOfActiveUsers,
438
439
  requestEmailChange,
439
440
  resetPassword,
@@ -851,6 +852,7 @@ declare module 'musora-content-services' {
851
852
  markNotificationAsRead,
852
853
  markNotificationAsUnread,
853
854
  markThreadAsRead,
855
+ maskEmail,
854
856
  numberOfActiveUsers,
855
857
  onProgressSaved,
856
858
  openComment,
package/src/index.js CHANGED
@@ -438,6 +438,7 @@ import {
438
438
  confirmEmailChange,
439
439
  createPendingAccount,
440
440
  deleteAccount,
441
+ maskEmail,
441
442
  numberOfActiveUsers,
442
443
  requestEmailChange,
443
444
  resetPassword,
@@ -850,6 +851,7 @@ export {
850
851
  markNotificationAsRead,
851
852
  markNotificationAsUnread,
852
853
  markThreadAsRead,
854
+ maskEmail,
853
855
  numberOfActiveUsers,
854
856
  onProgressSaved,
855
857
  openComment,
@@ -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
  )
@@ -9,6 +9,8 @@ import { Onboarding } from './onboarding'
9
9
  import { OAuthProvider } from './session'
10
10
  import { AuthResponse } from './types'
11
11
 
12
+ const LOCAL_PART_MASK = '***'
13
+
12
14
  export interface AccountStatus {
13
15
  requires_setup: boolean
14
16
  last_login_provider?: OAuthProvider
@@ -256,3 +258,24 @@ export async function toggleStudentView(useStudentView: boolean): Promise<UserRe
256
258
  const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
257
259
  return httpClient.patch<UserResource>(apiUrl, { use_student_view: useStudentView })
258
260
  }
261
+
262
+ /**
263
+ * Masks the local part of an email, keeping the domain intact.
264
+ * Shows the first character of the local part unless it's 3 characters or shorter,
265
+ * in which case the whole local part is masked.
266
+ * @example
267
+ * maskEmail('abcd@gmail.com') // 'a***@gmail.com'
268
+ * maskEmail('ab@abc.com') // '***@abc.com'
269
+ */
270
+ export function maskEmail(email: string): string {
271
+ const atIndex = email.indexOf('@')
272
+ if (atIndex === -1) {
273
+ return email
274
+ }
275
+ const localPart = email.slice(0, atIndex)
276
+ const domain = email.slice(atIndex)
277
+
278
+ return localPart.length <= 3
279
+ ? `${LOCAL_PART_MASK}${domain}`
280
+ : `${localPart[0]}${LOCAL_PART_MASK}${domain}`
281
+ }
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.185.0'
1
+ export const MCS_VERSION = '2.185.1'
Binary file
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });