-
Notifications
You must be signed in to change notification settings - Fork 3
feat: use discriminated JS return types (#11) #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
feat: use discriminated JS return types (#11) #15
Conversation
|
Some drive-by comments on this, although I'm unfamiliar with the code itself, and that will need @yokuze's review...
|
A drive-by on the drive-by 😆. Because these are discriminated unions, it doesn't look like we need StrictUnion or all the |
0f8ee3b to
0374488
Compare
0374488 to
620098e
Compare
|
Thanks @jthomerson, @onebytegone. I've squashed the commits, removed the unrelated package-lock changes^ and the ^Note: Will address the package-lock changes in a future PR to update npm/crate versions. |
yokuze
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the IRL brainstorming session! I left some comments with a few of the things we came up with.
Here's a draft of the concepts we talked about. Feel free to use some of it or none of it, whatever makes sense:
export enum DownloadStatus {
NotCreated = 'notCreated',
Created = 'created',
InProgress = 'inProgress',
Paused = 'paused',
Cancelled = 'cancelled',
Completed = 'completed',
Unknown = 'unknown',
}
export enum DownloadAction {
Create = 'create',
Start = 'start',
Resume = 'resume',
Cancel = 'cancel',
Pause = 'pause',
Listen = 'listen',
}
export interface DownloadState<S extends DownloadStatus> {
key: string;
url: string;
path: string;
progress: number;
status: S;
}
export interface DownloadActionResponse<A extends DownloadAction = DownloadAction> {
download: DownloadWithAnyStatus;
expectedStatus: ExpectedStatusesForAction<A>;
isExpectedStatus: boolean;
error?: Error;
}
export interface AllDownloadActions {
[DownloadAction.Listen]: (listener: (download: DownloadWithAnyStatus) => void) => Promise<UnlistenFn>;
[DownloadAction.Create]: () => Promise<DownloadActionResponse<DownloadAction.Create>>;
[DownloadAction.Start]: () => Promise<DownloadActionResponse<DownloadAction.Start>>;
[DownloadAction.Resume]: () => Promise<DownloadActionResponse<DownloadAction.Resume>>;
[DownloadAction.Cancel]: () => Promise<DownloadActionResponse<DownloadAction.Cancel>>;
[DownloadAction.Pause]: () => Promise<DownloadActionResponse<DownloadAction.Pause>>;
}
// Only these actions are allowed for each given DownloadStatus:
export const allowedActions = {
[DownloadStatus.NotCreated]: [
DownloadAction.Create,
DownloadAction.Start,
],
[DownloadStatus.Created]: [
DownloadAction.Listen,
DownloadAction.Start,
DownloadAction.Cancel,
],
[DownloadStatus.InProgress]: [
DownloadAction.Listen,
DownloadAction.Pause,
],
[DownloadStatus.Paused]: [
DownloadAction.Listen,
DownloadAction.Resume,
DownloadAction.Cancel,
],
[DownloadStatus.Completed]: [],
[DownloadStatus.Cancelled]: [],
[DownloadStatus.Unknown]: [
DownloadAction.Listen,
],
} satisfies Record<DownloadStatus, DownloadAction[] | []>;
export const expectedStatusesForAction = {
[DownloadAction.Create]: [ DownloadStatus.Created ],
[DownloadAction.Start]: [ DownloadStatus.InProgress ],
[DownloadAction.Cancel]: [ DownloadStatus.Cancelled ],
[DownloadAction.Pause]: [ DownloadStatus.Paused ],
[DownloadAction.Resume]: [ DownloadStatus.Paused ],
// Everything but "unknown" is valid:
[DownloadAction.Listen]: [
DownloadStatus.Cancelled,
DownloadStatus.Created,
DownloadStatus.Completed,
DownloadStatus.InProgress,
DownloadStatus.NotCreated,
DownloadStatus.Paused,
],
} satisfies Record<DownloadAction, DownloadStatus[] | []>;
type ActionsFns<S extends DownloadStatus> = Pick<AllDownloadActions, typeof allowedActions[S][number]>;
type AllowedActionsForStatus<S extends DownloadStatus> = ActionsFns<S> extends never ? object : ActionsFns<S>;
export type Download<S extends DownloadStatus> = DownloadState<S> & AllowedActionsForStatus<S>;
/**
* Union type representing a download in any status.
*
* To narrow the type to a more specific Download status, use either
* {@link hasAction `hasAction`} or the `status` field as a discriminator.
*
* @example
* ```ts
* if (hasAction(download, DownloadAction.Start)) {
* await download.start();
* }
*
* // Or:
* if (download.status === DownloadStatus.Created) {
* await download.start(); // TypeScript knows start() is available
* }
* ```
*/
export type DownloadWithAnyStatus = { [T in DownloadStatus]: Download<T> }[DownloadStatus];
export type ExpectedStatusesForAction<A extends DownloadAction> = (typeof expectedStatusesForAction)[A][number];
export type UnexpectedStatusesForAction<A extends DownloadAction> = Exclude<DownloadStatus, ExpectedStatusesForAction<A>>;
export type ExpectedStatesForAction<A extends DownloadAction> = Extract<DownloadWithAnyStatus, Pick<AllDownloadActions, A>>;
export type UnexpectedStatesForAction<A extends DownloadAction> = Exclude<DownloadWithAnyStatus, ExpectedStatesForAction<A>>;
type DownloadsWithAction<A extends DownloadAction> = Extract<DownloadWithAnyStatus, Pick<AllDownloadActions, A>>;
export function hasAction<A extends DownloadAction>(download: DownloadWithAnyStatus, actionName: A): download is DownloadsWithAction<A> {
return (allowedActions[download.status] as DownloadAction[]).includes(actionName);
}
/**
* @returns `true` if the download is in a state that does not allow any further actions,
* i.e. it is completed or cancelled
*/
export function hasAnyValidActions(download: DownloadWithAnyStatus): download is Exclude<DownloadWithAnyStatus, DownloadStatus.Completed | DownloadStatus.Cancelled> {
return download.status === DownloadStatus.Completed || download.status === DownloadStatus.Cancelled;
}
async function sendAction<A extends DownloadAction>(action: A, args: Record<string, unknown>): Promise<DownloadActionResponse<A>> {
const response = await invoke<DownloadActionResponse<A>>('plugin:download|' + action, args);
response.download = createDownload(response.download);
return response;
}
const actions = {
listen(listener: (download: DownloadWithAnyStatus) => void): Promise<UnlistenFn> {
return DownloadEventManager.shared.addListener(this.key, listener);
},
async create() {
return sendAction(DownloadAction.Create, { key: this.key, url: this.url, path: this.path });
},
async start() {
return sendAction(DownloadAction.Start, { key: this.key });
},
async resume() {
return sendAction(DownloadAction.Resume, { key: this.key });
},
async cancel() {
return sendAction(DownloadAction.Cancel, { key: this.key });
},
async pause() {
return sendAction(DownloadAction.Pause, { key: this.key });
},
} satisfies AllDownloadActions & ThisType<DownloadState<DownloadStatus>>;
/**
* Creates a Download object with the allowed actions for the given state
*
* @param state The download item from the plugin
*/
function createDownload<S extends DownloadStatus>(state: DownloadState<S>): Download<S> {
const download = {
key: state.key,
url: state.url,
path: state.path,
progress: state.progress,
status: state.status,
} satisfies DownloadState<S>;
const actionsForDownload = allowedActions[state.status];
for (const actionName of actionsForDownload) {
Object.defineProperty(download, actionName, {
value: actions[actionName],
});
}
return download as Download<S>;
}
/**
* @returns The list of download operations
*/
export async function list(): Promise<DownloadWithAnyStatus[]> {
return (await invoke<DownloadState<DownloadStatus>[]>('plugin:download|list'))
.map((item) => { return createDownload(item); });
}
/**
* @param key The download identifier
* @returns The download operation
*/
export async function get(key: string, url: string, path: string): Promise<DownloadWithAnyStatus> {
const download = await invoke<DownloadState<DownloadStatus> | undefined>('plugin:download|get', { key });
if (download) {
return createDownload(download);
}
return createDownload({ key, url, path, progress: 0, status: DownloadStatus.NotCreated });
}There are a couple things there that are different from what we came up with:
- A download in the
NotCreatedstate, for convenience' sake, has both.create()and.start()as possible actions. The Rust backend'sstarthandler would accept bothCreatedandNotCreateddownloads. IfNotCreated, it just creates and then starts. - Since we want to return a
NotCreateddownload to allow for listening for updates and taking an action later,getOrCreateis justgetto avoid confusion since it would returnNotCreatedif the download didn't already exist
Usage would look like this:
const props = defineProps<{ download: DownloadWithAnyStatus}>();
let unlisten: UnlistenFn | undefined;
const currentDownload = ref<DownloadWithAnyStatus>(props.download),
hasAnyActions = computed(() => { return !hasAnyValidActions(currentDownload.value); }),
canStart = computed(() => { return hasAction(currentDownload.value, DownloadAction.Start); }),
canCancel = computed(() => { return hasAction(currentDownload.value, DownloadAction.Cancel); }),
canPause = computed(() => { return hasAction(currentDownload.value, DownloadAction.Pause); }),
canResume = computed(() => { return hasAction(currentDownload.value, DownloadAction.Pause); });
onMounted(listenToEvents);
onUnmounted(() => { return unlisten?.(); });
async function listenToEvents(): Promise<void> {
if (unlisten || !hasAction(currentDownload.value, DownloadAction.Listen)) {
return;
}
unlisten = await currentDownload.value.listen((updated) => {
currentDownload.value = updated;
});
}
function onError(error: Error): void {
console.error(error);
}
type StatusHandlers<A extends DownloadAction> = Partial<{
[S in UnexpectedStatusesForAction<A>]: (actualState: Download<S>) => void;
}>;
type ActionHandlers = Partial<{
[K in DownloadAction]: StatusHandlers<K>;
}>;
const unexpectedStatusHandlers: ActionHandlers = {
[DownloadAction.Start]: {
[DownloadStatus.Cancelled]: () => {
// Tried to start the download but it was cancelled instead
},
},
[DownloadAction.Resume]: {
[DownloadStatus.Cancelled]: () => {
// Tried to start the download but it was cancelled instead
},
},
[DownloadAction.Cancel]: {
[DownloadStatus.Completed]: (): void => {
// You'll probably want to delete the file since the user wanted to cancel
// the download but wasn't able to before it completed
},
[DownloadStatus.InProgress]: (): void => {
// There was a problem cancelling the download
},
},
[DownloadAction.Pause]: {
[DownloadStatus.InProgress]: (): void => {
// There was a problem pausing the download
},
[DownloadStatus.Completed]: (): void => {
// The user tried to pause a completed download. This probably doesn't matter as
// much as the other cases
},
},
};
function handleUnexpectedStatus(action: DownloadAction, result: DownloadActionResponse<DownloadAction>): void {
const handlers = action in unexpectedStatusHandlers ? unexpectedStatusHandlers[action] : undefined;
if (!handlers) {
return;
}
const download = result.download,
status = download.status as keyof Required<typeof handlers>;
if (download.status === status && handlers[status]) {
handlers[status](download);
}
}
async function doAction<A extends Exclude<DownloadAction, DownloadAction.Listen>>(action: A): Promise<void> {
if (hasAction(currentDownload.value, action)) {
const result = await currentDownload.value[action]();
if (result.error) {
onError(result.error);
} else if (!result.isExpectedStatus) {
handleUnexpectedStatus(action, result as DownloadActionResponse<A>);
}
}
}| IN_PROGRESS = 'inProgress', | ||
| PAUSED = 'paused', | ||
| CANCELLED = 'cancelled', | ||
| COMPLETED = 'completed' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needs a trailing comma ,
| * Enum values are camel-cased to match the Rust and mobile plugin implementations. | ||
| */ | ||
| export enum DownloadState { | ||
| UNKNOWN = 'unknown', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These enum keys should be PascaleCase: https://github.com/silvermine/eslint-config-silvermine/blob/master/partials/typescript.js#L99
This and the lines below it should be indented 3 spaces
| @@ -1,6 +1,30 @@ | |||
| import { invoke, addPluginListener } from '@tauri-apps/api/core'; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Something is amiss with the ESLint config: it doesn't seem to be applying to this file. There are a few violations that should've been caught (outlined in comments below).
| * Represents a download item. | ||
| */ | ||
| export interface DownloadItem { | ||
| key: string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This and the lines below it should be indented 3 spaces
| /** | ||
| * Represents a download item. | ||
| */ | ||
| export interface DownloadItem { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have: DownloadState, DownloadItem, and Download as user-facing types. I wonder if we could clarify their purpose with the names:
DownloadStatusformerlyenum DownloadState- The download's current status. This implies thatDownloadItem'sstatefield would becomestatusDownloadStateformerlyinterface DownloadItem- an immutable, plain object that represents the complete serializable state of the download that comes over the IPCDownload- Stays as-is. Conceptually it'sDownloadState+ methods to take action on it (e.g.pause,play)
What do you think?
| * unlisten(); | ||
| * ``` | ||
| */ | ||
| public async listen(listener: (download: Download) => void): Promise<UnlistenFn> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ADIRL: do not allow listen for downloads that will never have updates, such as as those with status Cancelled and Completed
| * A download that is currently in progress. | ||
| * Can be paused or cancelled. | ||
| */ | ||
| export class ActiveDownload extends DownloadBase { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this represents an in-progress download and the status is also called IN_PROGRESS, should we call this InProgressDownload? Mostly for consistency's sake, but also "Active" could be taken to mean a download object that's able to be interacted with, e.g. by calling "start" on a paused download.
| public async start(): Promise<Download> { | ||
| return new Download(await invoke('plugin:download|start', { key: this.key })); | ||
| // eslint-disable-next-line @typescript-eslint/no-use-before-define | ||
| public async resume(): Promise<ActiveDownload> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ADIRL: The IPC will respond with some kind of response objects with a few different helpful properties. We would return that response object for each of these methods.
One of the response object's properties would be a Download object that represents the actual state at the time the Rust layer sends its response. In most cases, this would be a download object with the state you'd expect given the action you just took, e.g. for the resume method a Download object with status of InProgress. But it also allows us to return some other state in the case when the action we tried to take doesn't match up with the actual backend-state of the download, e.g. sending a "pause" action when the download is in a "Completed" state.
| * A download that has been cancelled. | ||
| * Terminal state - no further actions available. | ||
| */ | ||
| export class CancelledDownload extends DownloadBase { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These classes work and are fairly straightforward, but they have a couple disadvantages:
CancelledDownload,CompletedDownload, andUnknownDownloaddon't do anything other than change whatstateis initialized to.- Classes that have the same methods, e.g.
PausedDownload#cancel,ActiveDownload#cancel,CreatedDownload#cancelimplement thecancelmethod individually, 3 times. Granted, the implementation is pretty simple, but not-DRYing has the risk of diverging implementations over time
(See the code sample I left for one suggestion)
#11
Replaces the single
Downloadclass with a discriminated union of state-specific types, providing compile-time safety for download operations.Updated example app to use type guards.
Updated README with new API documentation.