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.
- package/.agent/decisions/2026-10-01-agi-remove-limit-from-queries.md +60 -0
- package/.claude/settings.local.json +3 -13
- package/CHANGELOG.md +2 -0
- package/package.json +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/services/content/artist.ts +4 -3
- package/src/services/content/genre.ts +4 -3
- package/src/services/content/instructor.ts +5 -4
- package/src/services/user/account.ts +23 -0
- package/src/version-info.js +1 -1
- package/.yarn/install-state.gz +0 -0
- package/dist/services/api/types.js +0 -2
|
@@ -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
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/version-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const MCS_VERSION = '2.185.
|
|
1
|
+
export const MCS_VERSION = '2.185.1'
|
package/.yarn/install-state.gz
DELETED
|
Binary file
|