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.
- package/.agent/decisions/2026-10-01-agi-remove-limit-from-queries.md +60 -0
- package/.claude/settings.local.json +9 -14
- package/CHANGELOG.md +10 -0
- package/jest.config.js +0 -1
- package/package.json +1 -1
- package/src/index.d.ts +17 -2
- package/src/index.js +17 -2
- package/src/lib/ads/async-either.ts +148 -0
- package/src/lib/ads/coproduct.ts +36 -0
- package/src/lib/sanity/decorators/navigate-to.ts +20 -14
- package/src/lib/sanity/examples.ts +27 -31
- package/src/lib/sanity/filter.ts +62 -0
- package/src/lib/sanity/groq.ts +60 -19
- package/src/lib/sanity/runner.ts +3 -2
- package/src/lib/sanity/types/marketing.d.ts +33 -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/content.js +181 -191
- package/src/services/endScreen/endScreen.ts +1 -1
- package/src/services/marketing/promotions.ts +178 -0
- package/src/services/recommendations.ts +307 -0
- package/src/services/sanity.js +1 -1
- 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
- package/src/services/recommendations.js +0 -142
|
@@ -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,17 +9,22 @@
|
|
|
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
|
-
"customerio"
|
|
23
|
+
"customerio",
|
|
24
|
+
"vercel",
|
|
25
|
+
"cloudflare"
|
|
26
|
+
],
|
|
27
|
+
"disabledMcpjsonServers": [
|
|
28
|
+
"mysql-archive"
|
|
34
29
|
]
|
|
35
30
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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.186.0](https://github.com/railroadmedia/musora-content-services/compare/v2.185.1...v2.186.0) (2026-10-16)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **MU2-1384:** Port recommendations to the new sanity builders ([#1058](https://github.com/railroadmedia/musora-content-services/issues/1058)) ([0e2e41f](https://github.com/railroadmedia/musora-content-services/commit/0e2e41f86e26e16af18b894ab2472d54e3008022))
|
|
11
|
+
* **MU2-2025:** Add promotional campaign queries and claim state ([#1081](https://github.com/railroadmedia/musora-content-services/issues/1081)) ([921ab1e](https://github.com/railroadmedia/musora-content-services/commit/921ab1effee488253413fd42146377e986571e45))
|
|
12
|
+
|
|
13
|
+
### [2.185.1](https://github.com/railroadmedia/musora-content-services/compare/v2.185.0...v2.185.1) (2026-10-06)
|
|
14
|
+
|
|
5
15
|
## [2.185.0](https://github.com/railroadmedia/musora-content-services/compare/v2.184.1...v2.185.0) (2026-10-01)
|
|
6
16
|
|
|
7
17
|
|
package/jest.config.js
CHANGED
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -81,7 +81,6 @@ import {
|
|
|
81
81
|
getNewAndUpcoming,
|
|
82
82
|
getOwnedContent,
|
|
83
83
|
getRecent,
|
|
84
|
-
getRecommendedForYou,
|
|
85
84
|
getScheduleContentRows,
|
|
86
85
|
getTabResults
|
|
87
86
|
} from './services/content.js';
|
|
@@ -217,6 +216,14 @@ import {
|
|
|
217
216
|
fetchMarketingTestimonials
|
|
218
217
|
} from './services/marketing/marketing.ts';
|
|
219
218
|
|
|
219
|
+
import {
|
|
220
|
+
claimPromotionalCampaign,
|
|
221
|
+
dismissPromotionalCampaign,
|
|
222
|
+
fetchAvailablePromotionalCampaigns,
|
|
223
|
+
fetchPromotionalCampaigns,
|
|
224
|
+
fetchUserCampaignStates
|
|
225
|
+
} from './services/marketing/promotions.ts';
|
|
226
|
+
|
|
220
227
|
import {
|
|
221
228
|
acceptInvite,
|
|
222
229
|
createAccount,
|
|
@@ -351,10 +358,11 @@ import {
|
|
|
351
358
|
|
|
352
359
|
import {
|
|
353
360
|
fetchSimilarItems,
|
|
361
|
+
getRecommendedForYou,
|
|
354
362
|
rankCategories,
|
|
355
363
|
rankItems,
|
|
356
364
|
recommendations
|
|
357
|
-
} from './services/recommendations.
|
|
365
|
+
} from './services/recommendations.ts';
|
|
358
366
|
|
|
359
367
|
import {
|
|
360
368
|
getReportIssueOptions,
|
|
@@ -434,6 +442,7 @@ import {
|
|
|
434
442
|
confirmEmailChange,
|
|
435
443
|
createPendingAccount,
|
|
436
444
|
deleteAccount,
|
|
445
|
+
maskEmail,
|
|
437
446
|
numberOfActiveUsers,
|
|
438
447
|
requestEmailChange,
|
|
439
448
|
resetPassword,
|
|
@@ -584,6 +593,7 @@ declare module 'musora-content-services' {
|
|
|
584
593
|
buildEntityAndTotalQuery,
|
|
585
594
|
buildImageSRC,
|
|
586
595
|
calculateLongestStreaks,
|
|
596
|
+
claimPromotionalCampaign,
|
|
587
597
|
clearAllCachedData,
|
|
588
598
|
clearState,
|
|
589
599
|
closeComment,
|
|
@@ -624,6 +634,7 @@ declare module 'musora-content-services' {
|
|
|
624
634
|
deleteProfilePicture,
|
|
625
635
|
deleteThread,
|
|
626
636
|
deleteUserActivity,
|
|
637
|
+
dismissPromotionalCampaign,
|
|
627
638
|
doesUserHaveMembership,
|
|
628
639
|
duplicatePlaylist,
|
|
629
640
|
editComment,
|
|
@@ -637,6 +648,7 @@ declare module 'musora-content-services' {
|
|
|
637
648
|
fetchArtistBySlug,
|
|
638
649
|
fetchArtistLessons,
|
|
639
650
|
fetchArtists,
|
|
651
|
+
fetchAvailablePromotionalCampaigns,
|
|
640
652
|
fetchBrandsByContentIds,
|
|
641
653
|
fetchByRailContentId,
|
|
642
654
|
fetchByRailContentIds,
|
|
@@ -703,6 +715,7 @@ declare module 'musora-content-services' {
|
|
|
703
715
|
fetchPost,
|
|
704
716
|
fetchPosts,
|
|
705
717
|
fetchPracticeGoals,
|
|
718
|
+
fetchPromotionalCampaigns,
|
|
706
719
|
fetchPublicAnnouncement,
|
|
707
720
|
fetchQuizAnswers,
|
|
708
721
|
fetchRecent,
|
|
@@ -731,6 +744,7 @@ declare module 'musora-content-services' {
|
|
|
731
744
|
fetchUninterests,
|
|
732
745
|
fetchUnreadCount,
|
|
733
746
|
fetchUpcomingEvents,
|
|
747
|
+
fetchUserCampaignStates,
|
|
734
748
|
fetchUserPermissions,
|
|
735
749
|
fetchUserPlaylists,
|
|
736
750
|
fetchUserPracticeMeta,
|
|
@@ -851,6 +865,7 @@ declare module 'musora-content-services' {
|
|
|
851
865
|
markNotificationAsRead,
|
|
852
866
|
markNotificationAsUnread,
|
|
853
867
|
markThreadAsRead,
|
|
868
|
+
maskEmail,
|
|
854
869
|
numberOfActiveUsers,
|
|
855
870
|
onProgressSaved,
|
|
856
871
|
openComment,
|
package/src/index.js
CHANGED
|
@@ -85,7 +85,6 @@ import {
|
|
|
85
85
|
getNewAndUpcoming,
|
|
86
86
|
getOwnedContent,
|
|
87
87
|
getRecent,
|
|
88
|
-
getRecommendedForYou,
|
|
89
88
|
getScheduleContentRows,
|
|
90
89
|
getTabResults
|
|
91
90
|
} from './services/content.js';
|
|
@@ -221,6 +220,14 @@ import {
|
|
|
221
220
|
fetchMarketingTestimonials
|
|
222
221
|
} from './services/marketing/marketing.ts';
|
|
223
222
|
|
|
223
|
+
import {
|
|
224
|
+
claimPromotionalCampaign,
|
|
225
|
+
dismissPromotionalCampaign,
|
|
226
|
+
fetchAvailablePromotionalCampaigns,
|
|
227
|
+
fetchPromotionalCampaigns,
|
|
228
|
+
fetchUserCampaignStates
|
|
229
|
+
} from './services/marketing/promotions.ts';
|
|
230
|
+
|
|
224
231
|
import {
|
|
225
232
|
acceptInvite,
|
|
226
233
|
createAccount,
|
|
@@ -355,10 +362,11 @@ import {
|
|
|
355
362
|
|
|
356
363
|
import {
|
|
357
364
|
fetchSimilarItems,
|
|
365
|
+
getRecommendedForYou,
|
|
358
366
|
rankCategories,
|
|
359
367
|
rankItems,
|
|
360
368
|
recommendations
|
|
361
|
-
} from './services/recommendations.
|
|
369
|
+
} from './services/recommendations.ts';
|
|
362
370
|
|
|
363
371
|
import {
|
|
364
372
|
getReportIssueOptions,
|
|
@@ -438,6 +446,7 @@ import {
|
|
|
438
446
|
confirmEmailChange,
|
|
439
447
|
createPendingAccount,
|
|
440
448
|
deleteAccount,
|
|
449
|
+
maskEmail,
|
|
441
450
|
numberOfActiveUsers,
|
|
442
451
|
requestEmailChange,
|
|
443
452
|
resetPassword,
|
|
@@ -583,6 +592,7 @@ export {
|
|
|
583
592
|
buildEntityAndTotalQuery,
|
|
584
593
|
buildImageSRC,
|
|
585
594
|
calculateLongestStreaks,
|
|
595
|
+
claimPromotionalCampaign,
|
|
586
596
|
clearAllCachedData,
|
|
587
597
|
clearState,
|
|
588
598
|
closeComment,
|
|
@@ -623,6 +633,7 @@ export {
|
|
|
623
633
|
deleteProfilePicture,
|
|
624
634
|
deleteThread,
|
|
625
635
|
deleteUserActivity,
|
|
636
|
+
dismissPromotionalCampaign,
|
|
626
637
|
doesUserHaveMembership,
|
|
627
638
|
duplicatePlaylist,
|
|
628
639
|
editComment,
|
|
@@ -636,6 +647,7 @@ export {
|
|
|
636
647
|
fetchArtistBySlug,
|
|
637
648
|
fetchArtistLessons,
|
|
638
649
|
fetchArtists,
|
|
650
|
+
fetchAvailablePromotionalCampaigns,
|
|
639
651
|
fetchBrandsByContentIds,
|
|
640
652
|
fetchByRailContentId,
|
|
641
653
|
fetchByRailContentIds,
|
|
@@ -702,6 +714,7 @@ export {
|
|
|
702
714
|
fetchPost,
|
|
703
715
|
fetchPosts,
|
|
704
716
|
fetchPracticeGoals,
|
|
717
|
+
fetchPromotionalCampaigns,
|
|
705
718
|
fetchPublicAnnouncement,
|
|
706
719
|
fetchQuizAnswers,
|
|
707
720
|
fetchRecent,
|
|
@@ -730,6 +743,7 @@ export {
|
|
|
730
743
|
fetchUninterests,
|
|
731
744
|
fetchUnreadCount,
|
|
732
745
|
fetchUpcomingEvents,
|
|
746
|
+
fetchUserCampaignStates,
|
|
733
747
|
fetchUserPermissions,
|
|
734
748
|
fetchUserPlaylists,
|
|
735
749
|
fetchUserPracticeMeta,
|
|
@@ -850,6 +864,7 @@ export {
|
|
|
850
864
|
markNotificationAsRead,
|
|
851
865
|
markNotificationAsUnread,
|
|
852
866
|
markThreadAsRead,
|
|
867
|
+
maskEmail,
|
|
853
868
|
numberOfActiveUsers,
|
|
854
869
|
onProgressSaved,
|
|
855
870
|
openComment,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Either } from './either'
|
|
2
|
+
|
|
3
|
+
const toError = (error: unknown, message?: string): Error =>
|
|
4
|
+
error instanceof Error
|
|
5
|
+
? error
|
|
6
|
+
: new Error(message ?? String(error ?? 'AsyncEither.attempt failed'))
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An {@link Either} that has not resolved yet, so a chain can keep going across asynchronous
|
|
10
|
+
* steps instead of dropping back into `.then()` at every one. Not a `TaskEither`: the work is
|
|
11
|
+
* already running when the AsyncEither is built.
|
|
12
|
+
* @example
|
|
13
|
+
* const lessons = await AsyncEither.of(run<Lesson[]>(groq))
|
|
14
|
+
* .map((lessons) => lessons ?? [])
|
|
15
|
+
* .mapAsync((lessons) => decorateNavigateTo(lessons))
|
|
16
|
+
* .ltap((error) => console.error(error.message))
|
|
17
|
+
* .recover([])
|
|
18
|
+
*/
|
|
19
|
+
export class AsyncEither<L, R> implements PromiseLike<Either<L, R>> {
|
|
20
|
+
private constructor(private readonly promise: Promise<Either<L, R>>) {}
|
|
21
|
+
|
|
22
|
+
/** @param promise - the pending Either to lift */
|
|
23
|
+
static of<L, R>(promise: Promise<Either<L, R>>): AsyncEither<L, R> {
|
|
24
|
+
return new AsyncEither(promise)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static attempt<R>(fn: () => Promise<R>, message?: string): AsyncEither<Error, R>
|
|
28
|
+
static attempt<R, L>(
|
|
29
|
+
fn: () => Promise<R>,
|
|
30
|
+
onError: (error: unknown) => L
|
|
31
|
+
): AsyncEither<L, R>
|
|
32
|
+
/**
|
|
33
|
+
* @param fn - the work to run; a rejection becomes a Left instead of propagating
|
|
34
|
+
* @param onError - a message used when `fn` rejects with a non-Error, or a function receiving
|
|
35
|
+
* the rejection untouched when the left has to carry more than a message
|
|
36
|
+
* @example
|
|
37
|
+
* AsyncEither.attempt(() => fetchStates(slugs), 'failed to fetch campaign state')
|
|
38
|
+
* @example
|
|
39
|
+
* AsyncEither.attempt(
|
|
40
|
+
* () => client.query(groq),
|
|
41
|
+
* (error) => new SanityQueryError('Sanity query failed', groq, error)
|
|
42
|
+
* )
|
|
43
|
+
*/
|
|
44
|
+
static attempt<R, L>(
|
|
45
|
+
fn: () => Promise<R>,
|
|
46
|
+
onError?: string | ((error: unknown) => L)
|
|
47
|
+
): AsyncEither<L | Error, R> {
|
|
48
|
+
const message = typeof onError === 'string' ? onError : undefined
|
|
49
|
+
const toLeft =
|
|
50
|
+
typeof onError === 'function' ? onError : (error: unknown) => toError(error, message)
|
|
51
|
+
|
|
52
|
+
return AsyncEither.of(
|
|
53
|
+
fn().then(
|
|
54
|
+
(value) => Either.right<L | Error, R>(value),
|
|
55
|
+
(error) => Either.left<L | Error, R>(toLeft(error))
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param fn - maps the right value once it resolves */
|
|
61
|
+
map<T>(fn: (r: R) => T): AsyncEither<L, T> {
|
|
62
|
+
return AsyncEither.of(this.promise.then((either) => either.map(fn)))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param fn - maps the right value with an asynchronous function; a rejection propagates */
|
|
66
|
+
mapAsync<T>(fn: (r: R) => Promise<T>): AsyncEither<L, T> {
|
|
67
|
+
return AsyncEither.of(this.promise.then((either) => either.mapAsync(fn)))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param fn - maps the right value to another AsyncEither, flattening the result; its left type
|
|
72
|
+
* may differ, in which case the result carries the union of both
|
|
73
|
+
*/
|
|
74
|
+
flatMap<T, E = L>(fn: (r: R) => AsyncEither<E, T>): AsyncEither<L | E, T> {
|
|
75
|
+
return AsyncEither.of(
|
|
76
|
+
this.promise.then((either) =>
|
|
77
|
+
either.fold(
|
|
78
|
+
(l) => Promise.resolve(Either.left<L | E, T>(l)),
|
|
79
|
+
(r) => fn(r).promise as Promise<Either<L | E, T>>
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param fn - maps the left value once it resolves */
|
|
86
|
+
lmap<T>(fn: (l: L) => T): AsyncEither<T, R> {
|
|
87
|
+
return AsyncEither.of(this.promise.then((either) => either.lmap(fn)))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param fn - maps the left value to another AsyncEither, flattening the result */
|
|
91
|
+
lflatMap<T>(fn: (l: L) => AsyncEither<T, R>): AsyncEither<T, R> {
|
|
92
|
+
return AsyncEither.of(
|
|
93
|
+
this.promise.then((either) =>
|
|
94
|
+
either.fold(
|
|
95
|
+
(l) => fn(l).promise,
|
|
96
|
+
(r) => Promise.resolve(Either.right<T, R>(r))
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param fn - visits the right value without modifying it */
|
|
103
|
+
tap(fn: (r: R) => void): AsyncEither<L, R> {
|
|
104
|
+
return AsyncEither.of(this.promise.then((either) => either.tap(fn)))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param fn - visits the left value without modifying it */
|
|
108
|
+
ltap(fn: (l: L) => void): AsyncEither<L, R> {
|
|
109
|
+
return AsyncEither.of(this.promise.then((either) => either.ltap(fn)))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param onLeft - handles the left value
|
|
114
|
+
* @param onRight - handles the right value
|
|
115
|
+
* @returns {Promise<T | U>}
|
|
116
|
+
*/
|
|
117
|
+
fold<T, U>(onLeft: (l: L) => T, onRight: (r: R) => U): Promise<T | U> {
|
|
118
|
+
return this.promise.then((either) => either.fold(onLeft, onRight))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param defaultValue - returned when the resolved Either is a Left
|
|
123
|
+
* @returns {Promise<R>}
|
|
124
|
+
*/
|
|
125
|
+
recover(defaultValue: R): Promise<R> {
|
|
126
|
+
return this.promise.then((either) => either.recover(defaultValue))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param error - thrown instead of the left, or used as the message of the thrown Error
|
|
131
|
+
* @returns {Promise<R>}
|
|
132
|
+
*/
|
|
133
|
+
orFail(error?: string | Error): Promise<R> {
|
|
134
|
+
return this.promise.then((either) => either.orFail(error))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @returns {Promise<Either<L, R>>} */
|
|
138
|
+
unlift(): Promise<Either<L, R>> {
|
|
139
|
+
return this.promise
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
then<T = Either<L, R>, U = never>(
|
|
143
|
+
onfulfilled?: ((either: Either<L, R>) => T | PromiseLike<T>) | null,
|
|
144
|
+
onrejected?: ((reason: unknown) => U | PromiseLike<U>) | null
|
|
145
|
+
): Promise<T | U> {
|
|
146
|
+
return this.promise.then(onfulfilled, onrejected)
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/lib/ads/coproduct.ts
CHANGED
|
@@ -84,6 +84,42 @@ export abstract class Coproduct<L, R>
|
|
|
84
84
|
*/
|
|
85
85
|
abstract fold<T, U>(onLeft: (l: L) => T, onRight: (r: R) => U): T | U
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Unwraps the right value, throwing the left instead of returning it. Use at the edge of a
|
|
89
|
+
* chain where a failure has to become an exception.
|
|
90
|
+
* @param error - thrown instead of the left, or used as the message of the thrown Error
|
|
91
|
+
* @throws the left value when it is an `Error`, otherwise an `Error` wrapping it
|
|
92
|
+
* @example
|
|
93
|
+
* Coproduct.right(5).orFail() // 5
|
|
94
|
+
* Coproduct.left(new Error('bad')).orFail() // throws Error('bad')
|
|
95
|
+
* Coproduct.left('bad').orFail('error during request') // throws Error('error during request')
|
|
96
|
+
* Coproduct.left('bad').orFail(new HttpError(502)) // throws HttpError(502)
|
|
97
|
+
*/
|
|
98
|
+
orFail(error?: string | Error): R {
|
|
99
|
+
return this.fold(
|
|
100
|
+
(l) => {
|
|
101
|
+
throw Coproduct.toError(l, error)
|
|
102
|
+
},
|
|
103
|
+
(r) => r
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static toError<L>(value: L, error?: string | Error): Error {
|
|
108
|
+
if (error instanceof Error) {
|
|
109
|
+
return error
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof error === 'string') {
|
|
113
|
+
return new Error(error)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (value instanceof Error) {
|
|
117
|
+
return value
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Error(typeof value === 'string' ? value : 'Coproduct.orFail called on a Left')
|
|
121
|
+
}
|
|
122
|
+
|
|
87
123
|
/** Convenience {@link fold} that applies the same `fn` regardless of side. */
|
|
88
124
|
foldMap<T>(initial: T, fn: (acc: T, value: L | R) => T): T {
|
|
89
125
|
return this.fold(
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
CollectionParameter,
|
|
5
5
|
STATE,
|
|
6
6
|
} from '../../../services/sync/models/ContentProgress'
|
|
7
|
-
import { Progress } from '
|
|
7
|
+
import { Progress } from '../../../services/progress'
|
|
8
8
|
|
|
9
9
|
export const NAVIGATE_TO_FIELD = 'navigateTo' as const
|
|
10
10
|
|
|
@@ -15,9 +15,10 @@ const NAVIGABLE_TYPES = [
|
|
|
15
15
|
'song-tutorial',
|
|
16
16
|
COLLECTION_TYPE.LEARNING_PATH,
|
|
17
17
|
'skill-pack',
|
|
18
|
+
'documentary',
|
|
18
19
|
] as const
|
|
19
20
|
|
|
20
|
-
const COURSE_FLOW_TYPES = ['course', 'skill-pack', 'song-tutorial']
|
|
21
|
+
const COURSE_FLOW_TYPES = ['course', 'skill-pack', 'song-tutorial', 'documentary']
|
|
21
22
|
const GUIDED_FLOW_TYPES = ['guided-course', COLLECTION_TYPE.LEARNING_PATH]
|
|
22
23
|
const TWO_DEPTH_TYPES = ['course-collection']
|
|
23
24
|
|
|
@@ -46,11 +47,18 @@ export type WithNavigateTo<T extends NavigateToDecoratable> = T & {
|
|
|
46
47
|
navigateTo: NavigateTo | null
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
const definedChildrenOf = (content: NavigateToDecoratable): NavigateToDecoratable[] =>
|
|
51
|
+
(content.children ?? []).filter(Boolean)
|
|
52
|
+
|
|
49
53
|
function buildNavigateTo(
|
|
50
|
-
content: NavigateToDecoratable,
|
|
54
|
+
content: NavigateToDecoratable | null | undefined,
|
|
51
55
|
child: NavigateTo | null = null,
|
|
52
56
|
collection: NavigateTo['collection'] = null
|
|
53
|
-
): NavigateTo {
|
|
57
|
+
): NavigateTo | null {
|
|
58
|
+
if (!content) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
return {
|
|
55
63
|
id: content.id,
|
|
56
64
|
type: content.type,
|
|
@@ -72,10 +80,10 @@ async function prefetchStates(items: NavigateToDecoratable[]): Promise<NavigateC
|
|
|
72
80
|
for (const item of items) {
|
|
73
81
|
if (!item || !NAVIGABLE_TYPES.includes(item.type as (typeof NAVIGABLE_TYPES)[number])) continue
|
|
74
82
|
ids.add(item.id)
|
|
75
|
-
for (const child of item
|
|
83
|
+
for (const child of definedChildrenOf(item)) {
|
|
76
84
|
ids.add(child.id)
|
|
77
85
|
if (TWO_DEPTH_TYPES.includes(item.type)) {
|
|
78
|
-
for (const grandchild of child
|
|
86
|
+
for (const grandchild of definedChildrenOf(child)) {
|
|
79
87
|
ids.add(grandchild.id)
|
|
80
88
|
}
|
|
81
89
|
}
|
|
@@ -92,8 +100,8 @@ async function computeNavigateTo(
|
|
|
92
100
|
): Promise<NavigateTo | null> {
|
|
93
101
|
if (!NAVIGABLE_TYPES.includes(content.type as (typeof NAVIGABLE_TYPES)[number])) return null
|
|
94
102
|
|
|
95
|
-
const children = content
|
|
96
|
-
if (
|
|
103
|
+
const children = definedChildrenOf(content)
|
|
104
|
+
if (children.length === 0) return null
|
|
97
105
|
|
|
98
106
|
const contentState = ctx?.states.get(content.id) ?? (await Progress.state(content.id))
|
|
99
107
|
if (contentState !== STATE.STARTED) {
|
|
@@ -117,14 +125,12 @@ async function computeNavigateTo(
|
|
|
117
125
|
lastInteractedStatus === STATE.STARTED
|
|
118
126
|
? lastInteractedId
|
|
119
127
|
: Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
|
|
120
|
-
|
|
121
|
-
return target ? buildNavigateTo(target) : null
|
|
128
|
+
return buildNavigateTo(childrenById.get(targetId))
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
if (GUIDED_FLOW_TYPES.includes(content.type)) {
|
|
125
132
|
const targetId = Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
|
|
126
|
-
|
|
127
|
-
return target ? buildNavigateTo(target) : null
|
|
133
|
+
return buildNavigateTo(childrenById.get(targetId))
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
if (TWO_DEPTH_TYPES.includes(content.type)) {
|
|
@@ -147,10 +153,10 @@ export function navigateToDecorator(
|
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
156
|
+
export async function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
151
157
|
items: T[]
|
|
152
158
|
): Promise<WithNavigateTo<T>[]>
|
|
153
|
-
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
159
|
+
export async function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
154
160
|
items: T
|
|
155
161
|
): Promise<WithNavigateTo<T>>
|
|
156
162
|
export async function decorateNavigateTo<T extends NavigateToDecoratable>(
|