add a createDeferred and DeferredPromise to the types

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-11-13 16:00:46 +01:00
parent 13180c5260
commit c5e39e78d7
18 changed files with 229 additions and 158 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/types': minor
---
Introduce `createDeferred` and `DeferredPromise`.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-backend-module-incremental-ingestion': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/config-loader': patch
---
Internal refactor to use the deferred from the types package
@@ -26,14 +26,7 @@ import {
PluginTaskSchedulerImpl,
parseDuration,
} from './PluginTaskSchedulerImpl';
function defer() {
let resolve = () => {};
const promise = new Promise<void>(_resolve => {
resolve = _resolve;
});
return { promise, resolve };
}
import { createDeferred } from '@backstage/types';
jest.setTimeout(60_000);
@@ -188,14 +181,14 @@ describe('PluginTaskManagerImpl', () => {
async databaseId => {
const { manager } = await init(databaseId);
const { promise, resolve } = defer();
const promise = createDeferred();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
resolve();
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'global',
@@ -313,14 +306,14 @@ describe('PluginTaskManagerImpl', () => {
it('cant trigger a running task', async () => {
const { manager } = await init('SQLITE_3');
const { promise, resolve } = defer();
const promise = createDeferred();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
resolve();
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'local',
+15 -22
View File
@@ -20,6 +20,7 @@ import fs from 'fs-extra';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { createDeferred } from '@backstage/types';
describe('loadConfig', () => {
const mockDir = createMockDirectory({
@@ -298,8 +299,8 @@ describe('loadConfig', () => {
});
it('watches config files', async () => {
const onChange = defer<AppConfig[]>();
const stopSignal = defer<void>();
const onChange = createDeferred<AppConfig[]>();
const stopSignal = createDeferred();
await expect(
loadConfig({
@@ -307,7 +308,7 @@ describe('loadConfig', () => {
configTargets: [],
watch: {
onChange: onChange.resolve,
stopSignal: stopSignal.promise,
stopSignal,
},
}),
).resolves.toEqual({
@@ -331,7 +332,7 @@ describe('loadConfig', () => {
title: 'New Title',
},
});
await expect(onChange.promise).resolves.toEqual([
await expect(onChange).resolves.toEqual([
{
context: 'app-config.yaml',
data: {
@@ -347,8 +348,8 @@ describe('loadConfig', () => {
});
it('watches included files', async () => {
const onChange = defer<AppConfig[]>();
const stopSignal = defer<void>();
const onChange = createDeferred<AppConfig[]>();
const stopSignal = createDeferred();
await expect(
loadConfig({
@@ -358,7 +359,7 @@ describe('loadConfig', () => {
],
watch: {
onChange: onChange.resolve,
stopSignal: stopSignal.promise,
stopSignal,
},
}),
).resolves.toEqual({
@@ -387,7 +388,7 @@ describe('loadConfig', () => {
// via included.yaml
await fs.writeFile(mockDir.resolve('secrets/session-key.txt'), 'abc234');
await expect(onChange.promise).resolves.toEqual([
await expect(onChange).resolves.toEqual([
{
context: 'app-config.development.yaml',
data: {
@@ -413,8 +414,8 @@ describe('loadConfig', () => {
it('watches remote config urls', async () => {
server.use(initialLoaderHandler);
const onChange = defer<AppConfig[]>();
const stopSignal = defer<void>();
const onChange = createDeferred<AppConfig[]>();
const stopSignal = createDeferred();
const configUrl = 'https://some.domain.io/app-config.yaml';
await expect(
@@ -423,7 +424,7 @@ describe('loadConfig', () => {
configTargets: [{ url: configUrl }],
watch: {
onChange: onChange.resolve,
stopSignal: stopSignal.promise,
stopSignal,
},
remote: {
reloadIntervalSeconds: 1,
@@ -446,7 +447,7 @@ describe('loadConfig', () => {
server.use(reloadHandler);
await expect(onChange.promise).resolves.toEqual([
await expect(onChange).resolves.toEqual([
{
context: configUrl,
data: {
@@ -463,7 +464,7 @@ describe('loadConfig', () => {
});
it('stops watching config files', async () => {
const stopSignal = defer<void>();
const stopSignal = createDeferred();
await loadConfig({
configRoot: mockDir.path,
@@ -472,7 +473,7 @@ describe('loadConfig', () => {
onChange: () => {
expect('not').toBe('called');
},
stopSignal: stopSignal.promise,
stopSignal,
},
});
@@ -496,12 +497,4 @@ describe('loadConfig', () => {
appConfigs: [],
});
});
function defer<T>() {
let resolve: (value: T) => void;
const promise = new Promise<T>(_resolve => {
resolve = _resolve;
});
return { promise, resolve: resolve! };
}
});
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
import { DeferredPromise, JsonObject, createDeferred } from '@backstage/types';
import {
AsyncConfigSourceGenerator,
ConfigSource,
ReadConfigDataOptions,
} from './types';
import { simpleDefer, SimpleDeferred, waitOrAbort } from './utils';
import { waitOrAbort } from './utils';
/**
* Options for {@link MutableConfigSource.create}.
@@ -52,20 +52,20 @@ export class MutableConfigSource implements ConfigSource {
}
#currentData?: JsonObject;
#deferred: SimpleDeferred<void>;
#deferred: DeferredPromise<void>;
readonly #context: string;
readonly #abortController = new AbortController();
private constructor(context: string, initialData?: JsonObject) {
this.#currentData = initialData;
this.#context = context;
this.#deferred = simpleDefer();
this.#deferred = createDeferred();
}
async *readConfigData(
options?: ReadConfigDataOptions | undefined,
): AsyncConfigSourceGenerator {
let deferredPromise = this.#deferred.promise;
let deferredPromise = this.#deferred;
if (this.#currentData !== undefined) {
yield { configs: [{ data: this.#currentData, context: this.#context }] };
@@ -79,7 +79,7 @@ export class MutableConfigSource implements ConfigSource {
if (!ok) {
return;
}
deferredPromise = this.#deferred.promise;
deferredPromise = this.#deferred;
if (this.#currentData !== undefined) {
yield {
@@ -98,7 +98,7 @@ export class MutableConfigSource implements ConfigSource {
if (!this.#abortController.signal.aborted) {
this.#currentData = data;
const oldDeferred = this.#deferred;
this.#deferred = simpleDefer();
this.#deferred = createDeferred();
oldDeferred.resolve();
}
}
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import { JsonObject, Observable } from '@backstage/types';
import { JsonObject, Observable, createDeferred } from '@backstage/types';
import {
AsyncConfigSourceGenerator,
ConfigSource,
ReadConfigDataOptions,
} from './types';
import { simpleDefer } from './utils';
/**
* Options for {@link StaticConfigSource.create}.
@@ -47,13 +46,13 @@ class StaticObservableConfigSource implements ConfigSource {
options?: ReadConfigDataOptions | undefined,
): AsyncConfigSourceGenerator {
const queue = new Array<JsonObject>();
let deferred = simpleDefer<void>();
let deferred = createDeferred();
const sub = this.data.subscribe({
next(value) {
queue.push(value);
deferred.resolve();
deferred = simpleDefer();
deferred = createDeferred();
},
complete() {
deferred.resolve();
@@ -73,7 +72,7 @@ class StaticObservableConfigSource implements ConfigSource {
}
for (;;) {
await deferred.promise;
await deferred;
if (queue.length === 0) {
return;
}
@@ -17,21 +17,6 @@
import { Parser } from './types';
import yaml from 'yaml';
/** @internal */
export interface SimpleDeferred<T> {
promise: Promise<T>;
resolve(value: T): void;
}
/** @internal */
export function simpleDefer<T>(): SimpleDeferred<T> {
let resolve: (value: T) => void;
const promise = new Promise<T>(_resolve => {
resolve = _resolve;
});
return { promise, resolve: resolve! };
}
/** @internal */
export async function waitOrAbort<T>(
promise: PromiseLike<T>,
+15
View File
@@ -3,6 +3,21 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// @public
export function createDeferred<
TResolved = void,
TRejected = Error,
>(): DeferredPromise<TResolved, TRejected>;
// @public
export type DeferredPromise<
TResolved = void,
TRejected = Error,
> = Promise<TResolved> & {
resolve(value: TResolved | PromiseLike<TResolved>): void;
reject(reason?: TRejected): void;
};
// @public
export function durationToMilliseconds(duration: HumanDuration): number;
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createDeferred } from './deferred';
describe('createDeferred', () => {
it('can both resolve and reject', async () => {
const d1 = createDeferred<number>();
const d2 = createDeferred<string>();
const d3 = createDeferred<object>();
const d4 = createDeferred(); // test the implicit void
const d1resolved = jest.fn<void, [number]>();
const d1rejected = jest.fn();
d1.then(d1resolved, d1rejected);
const d2resolved = jest.fn<void, [string]>();
const d2rejected = jest.fn();
d2.then(d2resolved, d2rejected);
const d3resolved = jest.fn<void, [number]>();
const d3rejected = jest.fn();
// @ts-expect-error wrong argument type for resolved
d3.then(d3resolved, d3rejected);
const d4resolved = jest.fn<void, [number]>();
const d4rejected = jest.fn();
// @ts-expect-error resolver should not take arguments for void deferred
d4.then(d4resolved, d4rejected);
d1.resolve(1);
d1.resolve(2); // should have no effect
// @ts-expect-error wrong argument type for resolve
d2.resolve(1);
d2.reject(new Error('boom')); // should have no effect
d3.reject(new Error('boom'));
// @ts-expect-error void deferred does not take arguments
d4.resolve(1);
await 'a tick';
expect(d1resolved).toHaveBeenCalledTimes(1);
expect(d1resolved).toHaveBeenCalledWith(1);
expect(d1rejected).not.toHaveBeenCalled();
expect(d1resolved).toHaveBeenCalledTimes(1);
expect(d2resolved).toHaveBeenCalledWith(1);
expect(d1rejected).not.toHaveBeenCalled();
expect(d3resolved).not.toHaveBeenCalled();
expect(d3rejected).toHaveBeenCalledTimes(1);
expect(d3rejected).toHaveBeenCalledWith(
expect.objectContaining({ message: 'boom' }),
);
expect(`${d1}`).toBe('[object DeferredPromise]');
});
});
+71
View File
@@ -0,0 +1,71 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A deferred promise that can be resolved or rejected later.
*
* @public
*/
export type DeferredPromise<
TResolved = void,
TRejected = Error,
> = Promise<TResolved> & {
resolve(value: TResolved | PromiseLike<TResolved>): void;
reject(reason?: TRejected): void;
};
class Deferred<TResolved, TRejected>
implements DeferredPromise<TResolved, TRejected>
{
#resolve?: (value: TResolved | PromiseLike<TResolved>) => void;
#reject?: (reason?: TRejected) => void;
public get resolve() {
return this.#resolve!;
}
public get reject() {
return this.#reject!;
}
public then: Promise<TResolved>['then'];
public catch: Promise<TResolved>['catch'];
public finally: Promise<TResolved>['finally'];
public constructor() {
const promise = new Promise<TResolved>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
});
this.then = promise.then.bind(promise);
this.catch = promise.catch.bind(promise);
this.finally = promise.finally.bind(promise);
}
[Symbol.toStringTag]: 'DeferredPromise' = 'DeferredPromise';
}
/**
* Creates a deferred promise that can be resolved or rejected later.
*
* @public
*/
export function createDeferred<
TResolved = void,
TRejected = Error,
>(): DeferredPromise<TResolved, TRejected> {
return new Deferred();
}
+1
View File
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export { createDeferred, type DeferredPromise } from './deferred';
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './json';
export type { Observable, Observer, Subscription } from './observable';
export { type HumanDuration, durationToMilliseconds } from './time';
@@ -24,6 +24,7 @@ import {
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { createDeferred } from '@backstage/types';
import express from 'express';
import { Knex } from 'knex';
import { Duration } from 'luxon';
@@ -35,7 +36,6 @@ import {
IncrementalEntityProvider,
IncrementalEntityProviderOptions,
} from '../types';
import { Deferred } from '../util';
/**
* Helps in the creation of the catalog entity providers that wrap the
@@ -44,7 +44,7 @@ import { Deferred } from '../util';
export class WrapperProviders {
private migrate: Promise<void> | undefined;
private numberOfProvidersToConnect = 0;
private readonly readySignal = new Deferred<void>();
private readonly readySignal = createDeferred();
constructor(
private readonly options: {
@@ -20,13 +20,13 @@ import {
PluginEnvironment,
} from '../types';
import { CatalogBuilder as CoreCatalogBuilder } from '@backstage/plugin-catalog-backend';
import { createDeferred } from '@backstage/types';
import { Duration } from 'luxon';
import { Knex } from 'knex';
import { IncrementalIngestionEngine } from '../engine/IncrementalIngestionEngine';
import { applyDatabaseMigrations } from '../database/migrations';
import { IncrementalIngestionDatabaseManager } from '../database/IncrementalIngestionDatabaseManager';
import { IncrementalProviderRouter } from '../router/routes';
import { Deferred } from '../util';
import { EventParams, EventSubscriber } from '@backstage/plugin-events-node';
/** @public */
@@ -43,16 +43,14 @@ export class IncrementalCatalogBuilder {
return new IncrementalCatalogBuilder(env, builder, client, manager);
}
private ready: Deferred<void>;
private ready = createDeferred();
private constructor(
private env: PluginEnvironment,
private builder: CoreCatalogBuilder,
private client: Knex,
private manager: IncrementalIngestionDatabaseManager,
) {
this.ready = new Deferred<void>();
}
) {}
async build() {
await applyDatabaseMigrations(this.client);
@@ -1,44 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export class Deferred<T> implements Promise<T> {
#resolve?: (value: T) => void;
#reject?: (error: Error) => void;
public get resolve() {
return this.#resolve!;
}
public get reject() {
return this.#reject!;
}
public then: Promise<T>['then'];
public catch: Promise<T>['catch'];
public finally: Promise<T>['finally'];
public constructor() {
const promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
});
this.then = promise.then.bind(promise);
this.catch = promise.catch.bind(promise);
this.finally = promise.finally.bind(promise);
}
[Symbol.toStringTag]: 'Deferred' = 'Deferred';
}
@@ -21,6 +21,7 @@ import {
startTestBackend,
} from '@backstage/backend-test-utils';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { createDeferred } from '@backstage/types';
import { Knex } from 'knex';
import { applyDatabaseMigrations } from '../../database/migrations';
import {
@@ -33,16 +34,6 @@ import { describePerformanceTest, performanceTraceEnabled } from './lib/env';
jest.setTimeout(600_000);
function defer<T>() {
let resolve: (value: T | PromiseLike<T>) => void;
let reject: (error?: unknown) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { promise, resolve: resolve!, reject: reject! };
}
const traceLog: typeof console.log = performanceTraceEnabled
? console.log
: () => {};
@@ -50,7 +41,7 @@ const traceLog: typeof console.log = performanceTraceEnabled
class Tracker {
private insertBaseEntitiesStart: number | undefined;
private insertBaseEntitiesEnd: number | undefined;
private readonly deferred = defer<void>();
private readonly deferred = createDeferred();
constructor(
private readonly knex: Knex,
@@ -93,7 +84,7 @@ class Tracker {
}
async completion(): Promise<void> {
return this.deferred.promise;
return this.deferred;
}
private completionPolling() {
@@ -15,6 +15,7 @@
*/
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import { createDeferred } from '@backstage/types';
import { screen } from '@testing-library/react';
import React from 'react';
import ObservableImpl from 'zen-observable';
@@ -25,14 +26,6 @@ import {
} from '../../apis';
import { EntityDisplayName } from './EntityDisplayName';
function defer<T>() {
let resolve = (_value: T) => {};
const promise = new Promise<T>(_resolve => {
resolve = _resolve;
});
return { promise, resolve };
}
describe('<EntityDisplayName />', () => {
const entityPresentationApi = {
forEntity: jest.fn(),
@@ -63,7 +56,7 @@ describe('<EntityDisplayName />', () => {
});
it('works with the async the happy path', async () => {
const { promise, resolve } = defer<EntityRefPresentationSnapshot>();
const promise = createDeferred<EntityRefPresentationSnapshot>();
entityPresentationApi.forEntity.mockReturnValue({
snapshot: {
@@ -89,7 +82,7 @@ describe('<EntityDisplayName />', () => {
expect(screen.getByText('foo')).toBeInTheDocument();
resolve({
promise.resolve({
entityRef: 'component:default/foo',
primaryTitle: 'bar',
});
@@ -22,14 +22,7 @@ import React from 'react';
import { useUnregisterEntityDialogState } from './useUnregisterEntityDialogState';
import { TestApiProvider } from '@backstage/test-utils';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
function defer<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => {};
const promise = new Promise<T>(_resolve => {
resolve = _resolve;
});
return { promise, resolve };
}
import { createDeferred } from '@backstage/types';
describe('useUnregisterEntityDialogState', () => {
const catalogApi = catalogApiMock.mock();
@@ -47,15 +40,15 @@ describe('useUnregisterEntityDialogState', () => {
beforeEach(() => {
jest.resetAllMocks();
const deferredLocation = defer<Location | undefined>();
const deferredColocatedEntities = defer<Entity[]>();
const deferredLocation = createDeferred<Location | undefined>();
const deferredColocatedEntities = createDeferred<Entity[]>();
resolveLocation = deferredLocation.resolve;
resolveColocatedEntities = deferredColocatedEntities.resolve;
catalogApi.getLocationByRef.mockReturnValue(deferredLocation.promise);
catalogApi.getLocationByRef.mockReturnValue(deferredLocation);
catalogApi.getEntities.mockReturnValue(
deferredColocatedEntities.promise.then(items => ({ items })),
deferredColocatedEntities.then(items => ({ items })),
);
entity = {
@@ -16,7 +16,12 @@
import { Config } from '@backstage/config';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import { JsonObject, JsonValue, Observable } from '@backstage/types';
import {
JsonObject,
JsonValue,
Observable,
createDeferred,
} from '@backstage/types';
import { Logger } from 'winston';
import ObservableImpl from 'zen-observable';
import {
@@ -260,14 +265,6 @@ export interface CurrentClaimedTask {
workspace?: Promise<Buffer>;
}
function defer() {
let resolve = () => {};
const promise = new Promise<void>(_resolve => {
resolve = _resolve;
});
return { promise, resolve };
}
export class StorageTaskBroker implements TaskBroker {
constructor(
private readonly storage: TaskStore,
@@ -301,7 +298,7 @@ export class StorageTaskBroker implements TaskBroker {
return await this.storage.list(options ?? {});
}
private deferredDispatch = defer();
private deferredDispatch = createDeferred();
private async registerCancellable(
taskId: string,
@@ -469,12 +466,12 @@ export class StorageTaskBroker implements TaskBroker {
}
private waitForDispatch() {
return this.deferredDispatch.promise;
return this.deferredDispatch;
}
private signalDispatch() {
this.deferredDispatch.resolve();
this.deferredDispatch = defer();
this.deferredDispatch = createDeferred();
}
async cancel(taskId: string) {