diff --git a/.changeset/stupid-flies-shake.md b/.changeset/stupid-flies-shake.md new file mode 100644 index 0000000000..c9e138272e --- /dev/null +++ b/.changeset/stupid-flies-shake.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-user-settings-backend': minor +'@backstage/plugin-user-settings-common': minor +'@backstage/plugin-user-settings': minor +--- + +User-settings will now use DataLoader to batch consecutive calls into one API call to improve performance diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index 75e74bfc55..917fd775b1 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -55,6 +55,7 @@ "@backstage/plugin-signals-node": "workspace:^", "@backstage/plugin-user-settings-common": "workspace:^", "@backstage/types": "workspace:^", + "already": "^2.2.1", "express": "^4.22.0", "express-promise-router": "^4.1.0", "knex": "^3.0.0" diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index 3a0de7054c..9e86c062a9 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -14,12 +14,17 @@ * limitations under the License. */ -import { InputError } from '@backstage/errors'; +import { InputError, serializeError } from '@backstage/errors'; +import { map } from 'already'; import express, { Request } from 'express'; import Router from 'express-promise-router'; import { UserSettingsStore } from '../database/UserSettingsStore'; import { SignalsService } from '@backstage/plugin-signals-node'; -import { UserSettingsSignal } from '@backstage/plugin-user-settings-common'; +import { + MultiUserSetting, + UserSettingsSignal, + parseDataLoaderKey, +} from '@backstage/plugin-user-settings-common'; import { HttpAuthService } from '@backstage/backend-plugin-api'; export async function createRouter(options: { @@ -40,6 +45,59 @@ export async function createRouter(options: { return credentials.principal.userEntityRef; }; + // get multiple values + router.get('/multi', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + + const bucketsAndKeys: ReturnType[] = []; + + const items = req.query.items; + if (typeof items === 'string') { + bucketsAndKeys.push(parseDataLoaderKey(items)); + } else if (Array.isArray(items)) { + bucketsAndKeys.push( + ...items.map(item => { + if (typeof item !== 'string') { + throw new InputError( + 'Expected query param "items" to be an array of strings', + ); + } + return parseDataLoaderKey(item); + }), + ); + } else { + throw new InputError('Expected query param "items" to be an array'); + } + + const userSettings = await map( + bucketsAndKeys, + { concurrency: 10 }, + async ({ bucket, key }): Promise => { + try { + const setting = await options.userSettingsStore.get({ + userEntityRef, + bucket, + key, + }); + return setting; + } catch (e) { + if (e instanceof Error) { + const serialized = serializeError(e); + return { bucket, key, error: serialized }; + } + + return { + bucket, + key, + error: { name: 'Error', message: 'Unknown error' }, + }; + } + }, + ); + + res.json(userSettings); + }); + // get a single value router.get('/buckets/:bucket/keys/:key(*)', async (req, res) => { const userEntityRef = await getUserEntityRef(req); diff --git a/plugins/user-settings-common/package.json b/plugins/user-settings-common/package.json index 82c5db84f1..0002355afb 100644 --- a/plugins/user-settings-common/package.json +++ b/plugins/user-settings-common/package.json @@ -37,6 +37,10 @@ "postpack": "backstage-cli package postpack", "test": "backstage-cli package test" }, + "dependencies": { + "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^" + }, "devDependencies": { "@backstage/cli": "workspace:^" } diff --git a/plugins/user-settings-common/report.api.md b/plugins/user-settings-common/report.api.md index b69f4a4190..03903d8107 100644 --- a/plugins/user-settings-common/report.api.md +++ b/plugins/user-settings-common/report.api.md @@ -3,6 +3,40 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import type { JsonValue } from '@backstage/types'; +import type { SerializedError } from '@backstage/errors'; + +// @public (undocumented) +export function isMultiUserSettingError( + setting: MultiUserSetting, +): setting is MultiUserSettingError; + +// @public +export type MultiUserSetting = MultiUserSettingError | MultiUserSettingSuccess; + +// @public +export type MultiUserSettingError = { + bucket: string; + key: string; + error: SerializedError; +}; + +// @public +export type MultiUserSettingSuccess = { + bucket: string; + key: string; + value: JsonValue; +}; + +// @public (undocumented) +export function parseDataLoaderKey(bucketAndKey: string): { + bucket: string; + key: string; +}; + +// @public (undocumented) +export function stringifyDataLoaderKey(bucket: string, key: string): string; + // @public (undocumented) export type UserSettingsSignal = { type: 'key-changed' | 'key-deleted'; diff --git a/plugins/user-settings-common/src/index.ts b/plugins/user-settings-common/src/index.ts index 5d542de408..43f85ada2b 100644 --- a/plugins/user-settings-common/src/index.ts +++ b/plugins/user-settings-common/src/index.ts @@ -15,3 +15,6 @@ */ export * from './types'; +export { isMultiUserSettingError } from './types'; + +export { stringifyDataLoaderKey, parseDataLoaderKey } from './keys'; diff --git a/plugins/user-settings-common/src/keys.ts b/plugins/user-settings-common/src/keys.ts new file mode 100644 index 0000000000..4a319dcd02 --- /dev/null +++ b/plugins/user-settings-common/src/keys.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2025 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. + */ + +/** @public */ +export function stringifyDataLoaderKey(bucket: string, key: string) { + return `${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`; +} + +/** @public */ +export function parseDataLoaderKey(bucketAndKey: string) { + const [bucket, key] = bucketAndKey.split('/'); + return { bucket: decodeURIComponent(bucket), key: decodeURIComponent(key) }; +} diff --git a/plugins/user-settings-common/src/types.ts b/plugins/user-settings-common/src/types.ts index 6a49a72c93..43fcef8a7d 100644 --- a/plugins/user-settings-common/src/types.ts +++ b/plugins/user-settings-common/src/types.ts @@ -14,8 +14,47 @@ * limitations under the License. */ +import type { SerializedError } from '@backstage/errors'; +import type { JsonValue } from '@backstage/types'; + /** @public */ export type UserSettingsSignal = { type: 'key-changed' | 'key-deleted'; key: string; }; + +/** + * A failed fetch of a user setting in a bucket + * + * @public + */ +export type MultiUserSettingError = { + bucket: string; + key: string; + error: SerializedError; +}; + +/** + * A successful value of a user setting in a bucket + * + * @public + */ +export type MultiUserSettingSuccess = { + bucket: string; + key: string; + value: JsonValue; +}; + +/** + * A single setting in a bucket, or an error, used result from the /multi endpoint + * + * @public + */ +export type MultiUserSetting = MultiUserSettingError | MultiUserSettingSuccess; + +/** @public */ +export function isMultiUserSettingError( + setting: MultiUserSetting, +): setting is MultiUserSettingError { + return 'error' in setting; +} diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 94c4c505d7..4853953f64 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -69,6 +69,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", + "dataloader": "^2.0.0", "react-use": "^17.2.4", "zen-observable": "^0.10.0" }, @@ -82,6 +83,7 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "@types/react": "^18.0.0", + "already": "^2.2.1", "msw": "^1.0.0", "react": "^18.0.2", "react-dom": "^18.0.2", diff --git a/plugins/user-settings/src/apis/StorageApi/CacheMap.ts b/plugins/user-settings/src/apis/StorageApi/CacheMap.ts new file mode 100644 index 0000000000..271ee93f67 --- /dev/null +++ b/plugins/user-settings/src/apis/StorageApi/CacheMap.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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 Map with TTL (time-to-live) support, for the Dataloader of user settings. + */ +export class CacheMap extends Map { + #ttlMs: number; + #timestamps: Map = new Map(); + + constructor(ttlMs: number) { + super(); + this.#ttlMs = ttlMs; + } + + set(key: K, value: V) { + const result = super.set(key, value); + this.#timestamps.set(key, Date.now()); + return result; + } + + get(key: K) { + if (!this.has(key)) { + return undefined; + } + const timestamp = this.#timestamps.get(key)!; + if (Date.now() - timestamp > this.#ttlMs) { + this.delete(key); + return undefined; + } + return super.get(key); + } + + delete(key: K) { + this.#timestamps.delete(key); + return super.delete(key); + } + + clear() { + this.#timestamps.clear(); + return super.clear(); + } +} diff --git a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts index e54ac36624..22f7f75ee3 100644 --- a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts @@ -25,6 +25,9 @@ import { mockApis, registerMswTestHooks, } from '@backstage/test-utils'; +import { NotFoundError } from '@backstage/errors'; +import { parseDataLoaderKey } from '@backstage/plugin-user-settings-common'; +import { defer } from 'already'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { UserSettingsStorage } from './UserSettingsStorage'; @@ -41,14 +44,12 @@ describe('Persistent Storage API', () => { const mockIdentityApi = mockApis.identity({ token: 'a-token' }); const mockIdentityApiFallback = mockApis.identity(); - const createPersistentStorage = ( - args?: Partial<{ - fetchApi: FetchApi; - discoveryApi: DiscoveryApi; - errorApi: ErrorApi; - namespace?: string; - }>, - ): StorageApi => { + const createPersistentStorage = (args: { + fetchApi?: FetchApi; + discoveryApi?: DiscoveryApi; + errorApi?: ErrorApi; + namespace: string; + }): StorageApi => { return UserSettingsStorage.create({ errorApi: mockErrorApi, fetchApi: new MockFetchApi(), @@ -58,14 +59,12 @@ describe('Persistent Storage API', () => { }); }; - const createPersistentStorageFallback = ( - args?: Partial<{ - fetchApi: FetchApi; - discoveryApi: DiscoveryApi; - errorApi: ErrorApi; - namespace?: string; - }>, - ): StorageApi => { + const createPersistentStorageFallback = (args: { + fetchApi?: FetchApi; + discoveryApi?: DiscoveryApi; + errorApi?: ErrorApi; + namespace: string; + }): StorageApi => { return UserSettingsStorage.create({ errorApi: mockErrorApi, fetchApi: new MockFetchApi(), @@ -75,21 +74,17 @@ describe('Persistent Storage API', () => { }); }; - afterEach(() => { + afterEach(async () => { + // Wait for server callbacks to settle before clearing the handlers. + // DataLoader delay is 10ms, so this should be plenty. + await new Promise(resolve => setTimeout(resolve, 100)); + jest.clearAllMocks(); + server.resetHandlers(); }); it('should return undefined for values which are unset', async () => { - const storage = createPersistentStorage(); - - server.use( - rest.get( - `${mockBaseUrl}/buckets/:bucket/keys/:key`, - async (_req, res, ctx) => { - return res(ctx.json({ value: 'a' })); - }, - ), - ); + const storage = createPersistentStorage({ namespace: 'undefined' }); expect(storage.snapshot('myfakekey').value).toBeUndefined(); expect(storage.snapshot('myfakekey')).toEqual({ @@ -100,7 +95,7 @@ describe('Persistent Storage API', () => { }); it('should allow setting of a simple data structure', async () => { - const storage = createPersistentStorage(); + const storage = createPersistentStorage({ namespace: 'simple' }); const dummyValue = 'a'; server.use( @@ -121,7 +116,7 @@ describe('Persistent Storage API', () => { }); it('should allow setting of a complex data structure', async () => { - const storage = createPersistentStorage(); + const storage = createPersistentStorage({ namespace: 'complex' }); const dummyValue = { some: 'nice data', with: { nested: 'values', nice: true }, @@ -144,7 +139,9 @@ describe('Persistent Storage API', () => { }); it('should fallback set when user not logged in', async () => { - const storage = createPersistentStorageFallback(); + const storage = createPersistentStorageFallback({ + namespace: 'not-logged-in', + }); const selectedKeyNextHandler = jest.fn(); const dummyValue = 'my-value'; @@ -171,12 +168,14 @@ describe('Persistent Storage API', () => { }); it('should subscribe to key changes when setting a new value', async () => { - const storage = createPersistentStorage(); + const storage = createPersistentStorage({ namespace: 'key-change-set' }); const wrongKeyNextHandler = jest.fn(); const selectedKeyNextHandler = jest.fn(); const mockData = { hello: 'im a great new value' }; + const serverCall = defer(undefined); + server.use( rest.put( `${mockBaseUrl}/buckets/:bucket/keys/:key`, @@ -188,6 +187,10 @@ describe('Persistent Storage API', () => { return res(ctx.json(data)); }, ), + rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => { + serverCall.resolve(); + return res(ctx.json([])); + }), ); await new Promise(resolve => { @@ -212,14 +215,18 @@ describe('Persistent Storage API', () => { presence: 'present', value: mockData, }); + + await serverCall.promise; }); it('should subscribe to key changes when deleting a value', async () => { - const storage = createPersistentStorage(); + const storage = createPersistentStorage({ namespace: 'key-change-delete' }); const wrongKeyNextHandler = jest.fn(); const selectedKeyNextHandler = jest.fn(); + const serverCall = defer(undefined); + server.use( rest.delete( `${mockBaseUrl}/buckets/:bucket/keys/:key`, @@ -227,6 +234,10 @@ describe('Persistent Storage API', () => { return res(ctx.status(204)); }, ), + rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => { + serverCall.resolve(); + return res(ctx.json([])); + }), ); await new Promise(resolve => { @@ -251,10 +262,12 @@ describe('Persistent Storage API', () => { presence: 'absent', value: undefined, }); + + await serverCall.promise; }); it('should not clash with other namespaces when creating buckets', async () => { - const rootStorage = createPersistentStorage(); + const rootStorage = createPersistentStorage({ namespace: 'clash' }); const selectedKeyNextHandler = jest.fn(); server.use( @@ -264,23 +277,22 @@ describe('Persistent Storage API', () => { const { bucket, key } = req.params; const { value } = await req.json(); - expect(bucket).toEqual('default.profile.something.deep'); + expect(bucket).toEqual('clash.profile.something.deep'); expect(key).toEqual('test2'); return res(ctx.json({ value })); }, ), - rest.get( - `${mockBaseUrl}/buckets/:bucket/keys/:key`, - async (req, res, ctx) => { - const { bucket, key } = req.params; + rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => { + const { bucket, key } = parseDataLoaderKey( + req.url.searchParams.get('items') as string, + ); - expect(bucket).toEqual('default.profile/something'); - expect(key).toEqual('deep/test2'); + expect(bucket).toEqual('clash.profile/something'); + expect(key).toEqual('deep/test2'); - return res(ctx.status(404)); - }, - ), + return res(ctx.status(404)); + }), ); // when getting key test2 it will translate to default.profile.something.deep/test2 @@ -321,15 +333,14 @@ describe('Persistent Storage API', () => { }); server.use( - rest.get( - `${mockBaseUrl}/buckets/:bucket/keys/:key`, - async (req, res, ctx) => { - const { bucket, key } = req.params; - expect(bucket).toEqual('Test.Mock.Thing'); - expect(key).toEqual('key'); - return res(ctx.text('{ invalid: json string }')); - }, - ), + rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => { + const { bucket, key } = parseDataLoaderKey( + req.url.searchParams.get('items') as string, + ); + expect(bucket).toEqual('Test.Mock.Thing'); + expect(key).toEqual('key'); + return res(ctx.text('{ invalid: json string }')); + }), ); await new Promise(resolve => { @@ -353,17 +364,14 @@ describe('Persistent Storage API', () => { }); it('should freeze the snapshot value', async () => { - const storage = createPersistentStorage(); + const storage = createPersistentStorage({ namespace: 'freeze' }); const selectedKeyNextHandler = jest.fn(); const data = { foo: 'bar', baz: [{ foo: 'bar' }] }; server.use( - rest.get( - `${mockBaseUrl}/buckets/:bucket/keys/:key`, - async (_req, res, ctx) => { - return res(ctx.json({ value: data })); - }, - ), + rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => { + return res(ctx.json([{ bucket: 'freeze', key: 'key', value: data }])); + }), ); await new Promise(resolve => { @@ -396,4 +404,112 @@ describe('Persistent Storage API', () => { snapshot.value.baz.push({ foo: 'buzz' }); }).toThrow(/Cannot add property 1, object is not extensible/); }); + + it('should batch multiple calls into one', async () => { + const storage = createPersistentStorage({ namespace: 'multiget' }); + const selectedKeyNextHandler = jest.fn(); + const selectedKeyNextHandlerCached = jest.fn(); + const data1 = { foo: 'bar1', baz: [{ foo: 'bar1' }] }; + const data2 = { foo: 'bar2', baz: [{ foo: 'bar2' }] }; + + let serverCalls = 0; + + server.use( + rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => { + ++serverCalls; + const result = req.url.searchParams + .getAll('items') + .map(item => parseDataLoaderKey(item)) + .map(({ key }) => { + if (key === 'key1') { + return { bucket: 'multiget', key, value: data1 }; + } else if (key === 'key2') { + return { bucket: 'multiget', key, value: data2 }; + } + return { bucket: 'multiget', key, error: new NotFoundError() }; + }); + + return res(ctx.json(result)); + }), + ); + + await Promise.all([ + new Promise(resolve => { + storage.observe$('key1').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'present') { + resolve(); + } + }, + }); + + storage.snapshot('key1'); + }), + + new Promise(resolve => { + storage.observe$('missing-key').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'absent') { + resolve(); + } + }, + }); + + storage.snapshot('missing-key'); + }), + + new Promise(resolve => { + storage.observe$('key2').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'present') { + resolve(); + } + }, + }); + + storage.snapshot('key2'); + }), + ]); + + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'key1', + presence: 'present', + value: data1, + }); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'key2', + presence: 'present', + value: data2, + }); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'missing-key', + presence: 'absent', + }); + expect(mockErrorApi.post).not.toHaveBeenCalled(); + expect(serverCalls).toBe(1); + + // Get key1 again, should use cached value + await new Promise(resolve => { + storage.observe$('key1').subscribe({ + next: snapshot => { + selectedKeyNextHandlerCached(snapshot); + if (snapshot.presence === 'present') { + resolve(); + } + }, + }); + + storage.snapshot('key1'); + }); + + expect(selectedKeyNextHandlerCached).toHaveBeenCalledWith({ + key: 'key1', + presence: 'present', + value: data1, + }); + expect(serverCalls).toBe(1); + }); }); diff --git a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts index aa3080345c..141aa0aa1a 100644 --- a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts @@ -23,11 +23,19 @@ import { StorageApi, StorageValueSnapshot, } from '@backstage/core-plugin-api'; -import { ResponseError } from '@backstage/errors'; +import { deserializeError, ResponseError } from '@backstage/errors'; import { JsonValue, Observable } from '@backstage/types'; import { SignalApi, SignalSubscriber } from '@backstage/plugin-signals-react'; import ObservableImpl from 'zen-observable'; -import { UserSettingsSignal } from '@backstage/plugin-user-settings-common'; +import { + isMultiUserSettingError, + MultiUserSetting, + parseDataLoaderKey, + stringifyDataLoaderKey, + UserSettingsSignal, +} from '@backstage/plugin-user-settings-common'; +import DataLoader from 'dataloader'; +import { CacheMap } from './CacheMap'; const JSON_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', @@ -36,6 +44,9 @@ const JSON_HEADERS = { const buckets = new Map(); +const DATALOADER_CACHE_TTL_MS = 2 * 1000; // 2 seconds cache +const DATALOADER_WINDOW_MS = 10; // 10 ms + /** * An implementation of the storage API, that uses the user-settings backend to * persist the data in the DB. @@ -59,6 +70,7 @@ export class UserSettingsStorage implements StorageApi { private readonly identityApi: IdentityApi; private readonly fallback: WebStorage; private readonly signalApi?: SignalApi; + private readonly userSettingsLoader: DataLoader; private constructor( namespace: string, @@ -68,6 +80,7 @@ export class UserSettingsStorage implements StorageApi { identityApi: IdentityApi, fallback: WebStorage, signalApi?: SignalApi, + userSettingsLoader?: DataLoader, ) { this.namespace = namespace; this.fetchApi = fetchApi; @@ -76,6 +89,27 @@ export class UserSettingsStorage implements StorageApi { this.identityApi = identityApi; this.fallback = fallback; this.signalApi = signalApi; + + this.userSettingsLoader = + userSettingsLoader ?? + new DataLoader( + async bucketAndKeyList => this.getMulti(bucketAndKeyList), + { + name: 'UserSettingsStorage.userSettingsLoader', + cacheMap: new CacheMap>( + DATALOADER_CACHE_TTL_MS, + ), + maxBatchSize: 100, + batchScheduleFn: cb => setTimeout(cb, DATALOADER_WINDOW_MS), + }, + ); + } + + private stringifyDataLoaderKey(key: string) { + return stringifyDataLoaderKey(this.namespace, key); + } + private clearCacheKey(key: string) { + this.userSettingsLoader.clear(this.stringifyDataLoaderKey(key)); } static create(options: { @@ -114,6 +148,8 @@ export class UserSettingsStorage implements StorageApi { this.errorApi, this.identityApi, this.fallback, + this.signalApi, + this.userSettingsLoader, ), ); } @@ -132,6 +168,8 @@ export class UserSettingsStorage implements StorageApi { throw await ResponseError.fromResponse(response); } + this.clearCacheKey(key); + this.notifyChanges({ key, presence: 'absent' }); } @@ -139,9 +177,12 @@ export class UserSettingsStorage implements StorageApi { if (!(await this.isSignedIn())) { await this.fallback.set(key, data); this.notifyChanges({ key, presence: 'present', value: data }); + this.clearCacheKey(key); return; } + this.clearCacheKey(key); + const fetchUrl = await this.getFetchUrl(key); const response = await this.fetchApi.fetch(fetchUrl, { @@ -156,6 +197,12 @@ export class UserSettingsStorage implements StorageApi { const { value } = await response.json(); + this.userSettingsLoader.prime(this.stringifyDataLoaderKey(key), { + key, + presence: 'present', + value, + }); + this.notifyChanges({ key, value, presence: 'present' }); } @@ -171,7 +218,9 @@ export class UserSettingsStorage implements StorageApi { const updateSnapshot = () => { Promise.resolve() - .then(() => this.get(key)) + .then(() => + this.userSettingsLoader.load(this.stringifyDataLoaderKey(key)), + ) .then(snapshot => subscriber.next(snapshot)) .catch(error => this.errorApi.post(error)); }; @@ -206,19 +255,30 @@ export class UserSettingsStorage implements StorageApi { return { key, presence: 'unknown' }; } - private async get( - key: string, - ): Promise> { + private async getMulti( + bucketAndKeyList: readonly string[], + ): Promise[]> { + if (bucketAndKeyList.length === 0) return []; + if (!(await this.isSignedIn())) { // This explicitly uses WebStorage, which we know is synchronous and doesn't return presence: unknown - return this.fallback.snapshot(key); + return bucketAndKeyList.map(bucketAndKey => + this.fallback.snapshot(parseDataLoaderKey(bucketAndKey).key), + ); } - const fetchUrl = await this.getFetchUrl(key); - const response = await this.fetchApi.fetch(fetchUrl); + const baseUrl = await this.discoveryApi.getBaseUrl('user-settings'); + const url = new URL(`${baseUrl}/multi`); + for (const bucketAndKey of bucketAndKeyList) { + url.searchParams.append('items', bucketAndKey); + } + const response = await this.fetchApi.fetch(url); if (response.status === 404) { - return { key, presence: 'absent' }; + return bucketAndKeyList.map(bucketAndKey => ({ + key: parseDataLoaderKey(bucketAndKey).key, + presence: 'absent', + })); } if (!response.ok) { @@ -226,18 +286,37 @@ export class UserSettingsStorage implements StorageApi { } try { - const { value: rawValue } = await response.json(); - const value = JSON.parse(JSON.stringify(rawValue), (_key, val) => { - if (typeof val === 'object' && val !== null) { - Object.freeze(val); - } - return val; - }); + const values = await response.json(); - return { key, presence: 'present', value }; + return (values as MultiUserSetting[]).map( + (setting): StorageValueSnapshot => { + if (isMultiUserSettingError(setting)) { + if (setting.error.name === 'NotFoundError') { + return { + key: setting.key, + presence: 'absent', + }; + } + throw deserializeError(setting.error); + } + return { + key: setting.key, + presence: 'present', + value: JSON.parse(JSON.stringify(setting.value), (_key, val) => { + if (typeof val === 'object' && val !== null) { + Object.freeze(val); + } + return val; + }), + }; + }, + ); } catch { // If the value is not valid JSON, we return an unknown presence. This should never happen - return { key, presence: 'absent' }; + return bucketAndKeyList.map(bucketAndKey => ({ + key: parseDataLoaderKey(bucketAndKey).key, + presence: 'absent', + })); } } diff --git a/yarn.lock b/yarn.lock index 309349d928..4704f1f38a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2291,7 +2291,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.8.3": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.8.3": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" dependencies: @@ -2440,6 +2440,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-plugin-utils@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-plugin-utils@npm:7.25.9" + checksum: 10/e347d87728b1ab10b6976d46403941c8f9008c045ea6d99997a7ffca7b852dc34b6171380f7b17edf94410e0857ff26f3a53d8618f11d73744db86e8ca9b8c64 + languageName: node + linkType: hard + "@babel/helper-replace-supers@npm:^7.25.0": version: 7.25.0 resolution: "@babel/helper-replace-supers@npm:7.25.0" @@ -2615,7 +2622,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.24.7, @babel/plugin-syntax-jsx@npm:^7.27.1": +"@babel/plugin-syntax-jsx@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/plugin-syntax-jsx@npm:7.24.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.24.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/a93516ae5b34868ab892a95315027d4e5e38e8bd1cfca6158f2974b0901cbb32bbe64ea10ad5b25f919ddc40c6d8113c4823372909c9c9922170c12b0b1acecb + languageName: node + linkType: hard + +"@babel/plugin-syntax-jsx@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-syntax-jsx@npm:7.27.1" dependencies: @@ -2714,7 +2732,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.24.7, @babel/plugin-syntax-typescript@npm:^7.27.1": +"@babel/plugin-syntax-typescript@npm:^7.24.7": + version: 7.25.9 + resolution: "@babel/plugin-syntax-typescript@npm:7.25.9" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.25.9" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/0e9821e8ba7d660c36c919654e4144a70546942ae184e85b8102f2322451eae102cbfadbcadd52ce077a2b44b400ee52394c616feab7b5b9f791b910e933fd33 + languageName: node + linkType: hard + +"@babel/plugin-syntax-typescript@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-syntax-typescript@npm:7.27.1" dependencies: @@ -2899,7 +2928,17 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.27.1, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/types@npm:7.28.4" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + checksum: 10/db50bf257aafa5d845ad16dae0587f57d596e4be4cbb233ea539976a4c461f9fbcc0bf3d37adae3f8ce5dcb4001462aa608f3558161258b585f6ce6ce21a2e45 + languageName: node + linkType: hard + +"@babel/types@npm:^7.27.3, @babel/types@npm:^7.28.5": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -7784,6 +7823,7 @@ __metadata: "@backstage/types": "workspace:^" "@types/express": "npm:^4.17.6" "@types/supertest": "npm:^2.0.8" + already: "npm:^2.2.1" express: "npm:^4.22.0" express-promise-router: "npm:^4.1.0" knex: "npm:^3.0.0" @@ -7796,6 +7836,8 @@ __metadata: resolution: "@backstage/plugin-user-settings-common@workspace:plugins/user-settings-common" dependencies: "@backstage/cli": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/types": "workspace:^" languageName: unknown linkType: soft @@ -7826,6 +7868,8 @@ __metadata: "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.0.0" "@types/react": "npm:^18.0.0" + already: "npm:^2.2.1" + dataloader: "npm:^2.0.0" msw: "npm:^1.0.0" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" @@ -10308,6 +10352,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + checksum: 10/ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee + languageName: node + linkType: hard + "@jest/expect@npm:30.2.0": version: 30.2.0 resolution: "@jest/expect@npm:30.2.0" @@ -13956,13 +14009,20 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": +"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": version: 1.38.0 resolution: "@opentelemetry/semantic-conventions@npm:1.38.0" checksum: 10/9d549f4896e900f644d5e70dd7142505daff88ed83c1cb7bcd976ac55e9496d4ddd686bb2815dd68655c739950514394c3b73ff51e53b2e4ff2d54a7f6d22521 languageName: node linkType: hard +"@opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0": + version: 1.34.0 + resolution: "@opentelemetry/semantic-conventions@npm:1.34.0" + checksum: 10/1892b4cc69c9e00456c809604a980e32696563e96463ff5f9d07e72d5aca73836a7378090509f28f54445ac6e072d2343a888c9d64d9ce287198e899082ff7aa + languageName: node + linkType: hard + "@opentelemetry/semantic-conventions@npm:~1.30.0": version: 1.30.0 resolution: "@opentelemetry/semantic-conventions@npm:1.30.0" @@ -17624,7 +17684,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^13.0.0, @sinonjs/fake-timers@npm:^13.0.1": +"@sinonjs/fake-timers@npm:^13.0.0": version: 13.0.5 resolution: "@sinonjs/fake-timers@npm:13.0.5" dependencies: @@ -17633,6 +17693,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^13.0.1": + version: 13.0.2 + resolution: "@sinonjs/fake-timers@npm:13.0.2" + dependencies: + "@sinonjs/commons": "npm:^3.0.1" + checksum: 10/77cca5c548e2529931908c48ac375f162ee901bc52110197b4c470b2535c6c571f9ecd4fa12157f4d2ae174c5391f03940fb563a681a691fb44204a0ef3ded35 + languageName: node + linkType: hard + "@sinonjs/samsam@npm:^8.0.0": version: 8.0.0 resolution: "@sinonjs/samsam@npm:8.0.0" @@ -17720,7 +17789,14 @@ __metadata: languageName: node linkType: hard -"@slack/types@npm:^2.11.0, @slack/types@npm:^2.13.0, @slack/types@npm:^2.14.0, @slack/types@npm:^2.18.0": +"@slack/types@npm:^2.11.0, @slack/types@npm:^2.13.0, @slack/types@npm:^2.14.0": + version: 2.14.0 + resolution: "@slack/types@npm:2.14.0" + checksum: 10/fa24a113b88e087f899078504c2ba50ab9795f7c2dd1a2d95b28217a3af20e554494f9cc3b8c8ce173120990d98e19400c95369f9067cecfcc46c08b59d2a46f + languageName: node + linkType: hard + +"@slack/types@npm:^2.18.0": version: 2.19.0 resolution: "@slack/types@npm:2.19.0" checksum: 10/d5fbc227147b32a964072cb46e2c328b896507a374194532ddf594e18e4ccc2afe9f31f2b92d14ace43ef856b22979523e565616be2c87a2dc6c575d4f919fd0 @@ -18378,7 +18454,7 @@ __metadata: languageName: node linkType: hard -"@smithy/signature-v4@npm:^4.1.0, @smithy/signature-v4@npm:^4.1.1": +"@smithy/signature-v4@npm:^4.1.0": version: 4.2.4 resolution: "@smithy/signature-v4@npm:4.2.4" dependencies: @@ -18394,6 +18470,22 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^4.1.1": + version: 4.1.1 + resolution: "@smithy/signature-v4@npm:4.1.1" + dependencies: + "@smithy/is-array-buffer": "npm:^3.0.0" + "@smithy/protocol-http": "npm:^4.1.1" + "@smithy/types": "npm:^3.4.0" + "@smithy/util-hex-encoding": "npm:^3.0.0" + "@smithy/util-middleware": "npm:^3.0.4" + "@smithy/util-uri-escape": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^3.0.0" + tslib: "npm:^2.6.2" + checksum: 10/f77d8b6cb384a0b9a0925ce6a693fbbcbf13c6f9ddf12cb9e1b39fb06452d001b17164d5e2618ea103edb427f2f1225d057827e2815e629b582115b2d533194f + languageName: node + linkType: hard + "@smithy/signature-v4@npm:^5.3.5": version: 5.3.5 resolution: "@smithy/signature-v4@npm:5.3.5" @@ -18689,7 +18781,7 @@ __metadata: languageName: node linkType: hard -"@smithy/util-middleware@npm:^3.0.11, @smithy/util-middleware@npm:^3.0.4": +"@smithy/util-middleware@npm:^3.0.11": version: 3.0.11 resolution: "@smithy/util-middleware@npm:3.0.11" dependencies: @@ -18699,6 +18791,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-middleware@npm:^3.0.4": + version: 3.0.4 + resolution: "@smithy/util-middleware@npm:3.0.4" + dependencies: + "@smithy/types": "npm:^3.4.0" + tslib: "npm:^2.6.2" + checksum: 10/d358c8e5ab462b749b32421135f1660302de8b2a942e5cf92b63b1489e87d0fc57aa4cc578fee46a7192472946b06a898d98b1f01c8cf4b9fccc4ad489ffde5a + languageName: node + linkType: hard + "@smithy/util-middleware@npm:^4.2.5": version: 4.2.5 resolution: "@smithy/util-middleware@npm:4.2.5" @@ -21004,14 +21106,14 @@ __metadata: linkType: hard "@types/express-serve-static-core@npm:*": - version: 5.1.1 - resolution: "@types/express-serve-static-core@npm:5.1.1" + version: 4.19.6 + resolution: "@types/express-serve-static-core@npm:4.19.6" dependencies: "@types/node": "npm:*" "@types/qs": "npm:*" "@types/range-parser": "npm:*" "@types/send": "npm:*" - checksum: 10/7f3d8cf7e68764c9f3e8f6a12825b69ccf5287347fc1c20b29803d4f08a4abc1153ae11d7258852c61aad50f62ef72d4c1b9c97092b0a90462c3dddec2f6026c + checksum: 10/a2e00b6c5993f0dd63ada2239be81076fe0220314b9e9fde586e8946c9c09ce60f9a2dd0d74410ee2b5fd10af8c3e755a32bb3abf134533e2158142488995455 languageName: node linkType: hard @@ -21036,7 +21138,19 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.16.1, @types/express@npm:^4.17.21, @types/express@npm:^4.17.25, @types/express@npm:^4.17.6": +"@types/express@npm:*, @types/express@npm:^4.16.1, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": + version: 4.17.23 + resolution: "@types/express@npm:4.17.23" + dependencies: + "@types/body-parser": "npm:*" + "@types/express-serve-static-core": "npm:^4.17.33" + "@types/qs": "npm:*" + "@types/serve-static": "npm:*" + checksum: 10/cf4d540bbd90801cdc79a46107b8873404698a7fd0c3e8dd42989d52d3bd7f5b8768672e54c20835e41e27349c319bb47a404ad14c0f8db0e9d055ba1cb8a05b + languageName: node + linkType: hard + +"@types/express@npm:^4.17.25": version: 4.17.25 resolution: "@types/express@npm:4.17.25" dependencies: @@ -21398,14 +21512,7 @@ __metadata: languageName: node linkType: hard -"@types/luxon@npm:^3.0.0": - version: 3.7.1 - resolution: "@types/luxon@npm:3.7.1" - checksum: 10/c7bc164c278393ea0be938f986c74b4cddfab9013b1aff4495b016f771ded1d5b7b7b4825b2c7f0b8799edce19c5f531c28ff434ab3dedf994ac2d99a20fd4c4 - languageName: node - linkType: hard - -"@types/luxon@npm:~3.4.0": +"@types/luxon@npm:^3.0.0, @types/luxon@npm:~3.4.0": version: 3.4.2 resolution: "@types/luxon@npm:3.4.2" checksum: 10/fd89566e3026559f2bc4ddcc1e70a2c16161905ed50be9473ec0cfbbbe919165041408c4f6e06c4bcf095445535052e2c099087c76b1b38e368127e618fc968d @@ -21530,7 +21637,7 @@ __metadata: languageName: node linkType: hard -"@types/node-fetch@npm:^2.6.13, @types/node-fetch@npm:^2.6.4": +"@types/node-fetch@npm:^2.6.13": version: 2.6.13 resolution: "@types/node-fetch@npm:2.6.13" dependencies: @@ -21540,6 +21647,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.4": + version: 2.6.12 + resolution: "@types/node-fetch@npm:2.6.12" + dependencies: + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10/8107c479da83a3114fcbfa882eba95ee5175cccb5e4dd53f737a96f2559ae6262f662176b8457c1656de09ec393cc7b20a266c077e4bfb21e929976e1cf4d0f9 + languageName: node + linkType: hard + "@types/node-forge@npm:^1.3.0": version: 1.3.14 resolution: "@types/node-forge@npm:1.3.14" @@ -22058,6 +22175,17 @@ __metadata: languageName: node linkType: hard +"@types/serve-static@npm:*": + version: 1.15.7 + resolution: "@types/serve-static@npm:1.15.7" + dependencies: + "@types/http-errors": "npm:*" + "@types/node": "npm:*" + "@types/send": "npm:*" + checksum: 10/c5a7171d5647f9fbd096ed1a26105759f3153ccf683824d99fee4c7eb9cde2953509621c56a070dd9fb1159e799e86d300cbe4e42245ebc5b0c1767e8ca94a67 + languageName: node + linkType: hard + "@types/serve-static@npm:^1, @types/serve-static@npm:^1.15.5": version: 1.15.10 resolution: "@types/serve-static@npm:1.15.10" @@ -22136,6 +22264,13 @@ __metadata: languageName: node linkType: hard +"@types/stack-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "@types/stack-utils@npm:2.0.0" + checksum: 10/b3fbae25b073116977ecb5c67d22f14567b51a7792403b0bf46e5de8f29bde3bd4ec1626afb22065495ca7f1c699c8bd66720050c94b8f8f9bcefbee79d161fd + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" @@ -22371,7 +22506,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.33, @types/yargs@npm:^17.0.8": +"@types/yargs@npm:^17.0.33": version: 17.0.35 resolution: "@types/yargs@npm:17.0.35" dependencies: @@ -22380,6 +22515,15 @@ __metadata: languageName: node linkType: hard +"@types/yargs@npm:^17.0.8": + version: 17.0.12 + resolution: "@types/yargs@npm:17.0.12" + dependencies: + "@types/yargs-parser": "npm:*" + checksum: 10/ffbbfad0c75cc058e0518f202e3651b9fb60c7c1325240cc72ac0a022da746a759ba3c6e0099152076fed5012fb694eb4685e4520c04dc8399c22bb81221bff5 + languageName: node + linkType: hard + "@types/yarnpkg__lockfile@npm:^1.1.4": version: 1.1.9 resolution: "@types/yarnpkg__lockfile@npm:1.1.9" @@ -22447,6 +22591,19 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/project-service@npm:8.35.0": + version: 8.35.0 + resolution: "@typescript-eslint/project-service@npm:8.35.0" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.35.0" + "@typescript-eslint/types": "npm:^8.35.0" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/a9419da92231aa27f75078fcffab1d02398b50fdb7d5399775a414ba02570682b4b60cdfafb544a021b0dc2372f029c4195f5ae17c50deb11c25661b2ac18a74 + languageName: node + linkType: hard + "@typescript-eslint/project-service@npm:8.48.1": version: 8.48.1 resolution: "@typescript-eslint/project-service@npm:8.48.1" @@ -22503,6 +22660,15 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/tsconfig-utils@npm:8.35.0, @typescript-eslint/tsconfig-utils@npm:^8.35.0": + version: 8.35.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.35.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/4160928313ccbe8b169a009b9c1220826c7df7aab427f960c31f3b838931bc7a121ebee8040118481e4528e2e3cf1b26da047c6ac1d802ecff2ef7206026ea6b + languageName: node + linkType: hard + "@typescript-eslint/tsconfig-utils@npm:8.48.1": version: 8.48.1 resolution: "@typescript-eslint/tsconfig-utils@npm:8.48.1" @@ -22544,6 +22710,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.35.0, @typescript-eslint/types@npm:^8.35.0": + version: 8.35.0 + resolution: "@typescript-eslint/types@npm:8.35.0" + checksum: 10/34b5e6da2c59ea84cd528608fff0cc14b102fd23f5517dfee4ef38c9372861d80b5bf92445c9679674f0a4f8dc4ded5066c1bca2bc5569c47515f94568984f35 + languageName: node + linkType: hard + "@typescript-eslint/types@npm:8.48.1": version: 8.48.1 resolution: "@typescript-eslint/types@npm:8.48.1" @@ -22596,7 +22769,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.54.0, @typescript-eslint/typescript-estree@npm:^8.23.0": +"@typescript-eslint/typescript-estree@npm:8.54.0": version: 8.54.0 resolution: "@typescript-eslint/typescript-estree@npm:8.54.0" dependencies: @@ -22615,6 +22788,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:^8.23.0": + version: 8.35.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.35.0" + dependencies: + "@typescript-eslint/project-service": "npm:8.35.0" + "@typescript-eslint/tsconfig-utils": "npm:8.35.0" + "@typescript-eslint/types": "npm:8.35.0" + "@typescript-eslint/visitor-keys": "npm:8.35.0" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <5.9.0" + checksum: 10/4dff7c5a8853c8f4e30d35565c62d3ad5bf8445309bd465d94e9bca725853012bb9f58896a04207c30e10b6669511caac8c0f080ed781c93a3db81d5808195aa + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.48.1": version: 8.48.1 resolution: "@typescript-eslint/utils@npm:8.48.1" @@ -22669,6 +22862,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.35.0": + version: 8.35.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.35.0" + dependencies: + "@typescript-eslint/types": "npm:8.35.0" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/c0acb13aac3a2be5e82844f7d2e86137347efdd04661dbf9fa69ef04a19dd2f1eb2f1eb6bfbfbaada78a46884308d2c0e0b5d0d1a094c84f2dfb670b67ac2b3b + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:8.48.1": version: 8.48.1 resolution: "@typescript-eslint/visitor-keys@npm:8.48.1" @@ -24494,6 +24697,13 @@ __metadata: languageName: node linkType: hard +"already@npm:^2.2.1": + version: 2.2.1 + resolution: "already@npm:2.2.1" + checksum: 10/1c55b50667c3dbe9d40716454d4a870f5758143061a0e39c0a7077eab2c6dbec116edf081796afb6f441462096bf68ef72a4daad074843a0d970527f29037ffe + languageName: node + linkType: hard + "anser@npm:^2.1.1": version: 2.3.3 resolution: "anser@npm:2.3.3" @@ -25287,7 +25497,18 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4": +"axios@npm:^1.0.0, axios@npm:^1.7.4": + version: 1.12.2 + resolution: "axios@npm:1.12.2" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10/886a79770594eaad76493fecf90344b567bd956240609b5dcd09bd0afe8d3e6f1ad6d3257a93a483b6192b409d4b673d9515a34619e3e3ed1b2c0ec2a83b20ba + languageName: node + linkType: hard + +"axios@npm:^1.11.0, axios@npm:^1.12.2, axios@npm:^1.13.0": version: 1.13.4 resolution: "axios@npm:1.13.4" dependencies: @@ -28498,15 +28719,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.3": - version: 4.4.3 - resolution: "debug@npm:4.4.3" +"debug@npm:4, debug@npm:^4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad + checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 languageName: node linkType: hard @@ -28531,6 +28752,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.7, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad + languageName: node + linkType: hard + "debuglog@npm:^1.0.1": version: 1.0.1 resolution: "debuglog@npm:1.0.1" @@ -30674,13 +30907,20 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": +"eventsource-parser@npm:^3.0.0": version: 3.0.6 resolution: "eventsource-parser@npm:3.0.6" checksum: 10/febf7058b9c2168ecbb33e92711a1646e06bd1568f60b6eb6a01a8bf9f8fcd29cc8320d57247059cacf657a296280159f21306d2e3ff33309a9552b2ef889387 languageName: node linkType: hard +"eventsource-parser@npm:^3.0.1": + version: 3.0.1 + resolution: "eventsource-parser@npm:3.0.1" + checksum: 10/2730c54c3cb47d55d2967f2ece843f9fc95d8a11c2fef6fece8d17d9080193cbe3cd9ac7b04a325977f63cbf8c1664fdd0512dec1aec601666a5c5bd8564b61f + languageName: node + linkType: hard + "eventsource@npm:^3.0.2": version: 3.0.7 resolution: "eventsource@npm:3.0.7" @@ -30989,7 +31229,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:30.2.0, expect@npm:>28.1.3, expect@npm:^30.0.0": +"expect@npm:30.2.0, expect@npm:^30.0.0": version: 30.2.0 resolution: "expect@npm:30.2.0" dependencies: @@ -31003,6 +31243,19 @@ __metadata: languageName: node linkType: hard +"expect@npm:>28.1.3": + version: 29.7.0 + resolution: "expect@npm:29.7.0" + dependencies: + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -32031,7 +32284,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:11.3.3, fs-extra@npm:^11.0.0, fs-extra@npm:^11.1.0, fs-extra@npm:^11.2.0, fs-extra@npm:~11.3.0": +"fs-extra@npm:11.3.3, fs-extra@npm:~11.3.0": version: 11.3.3 resolution: "fs-extra@npm:11.3.3" dependencies: @@ -32065,6 +32318,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^11.0.0, fs-extra@npm:^11.1.0, fs-extra@npm:^11.2.0": + version: 11.3.1 + resolution: "fs-extra@npm:11.3.1" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10/2b893213411b1da11f9b061ccb0bcff4d6dd66fe90aa8f5b1616219a5e7ca659da869f454ebd8e94aa21c58342730fb43a2e5c98b5c6c5124f0c54a4633f64b0 + languageName: node + linkType: hard + "fs-extra@npm:^7.0.1": version: 7.0.1 resolution: "fs-extra@npm:7.0.1" @@ -32342,7 +32606,25 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10/6e9dd920ff054147b6f44cb98104330e87caafae051b6d37b13384a45ba15e71af33c3baeac7cb630a0aaa23142718dcf25b45cfdd86c184c5dcb4e56d953a10 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.2": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -35549,7 +35831,7 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.2.0": +"jest-diff@npm:^29.2.0, jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" dependencies: @@ -35649,6 +35931,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 + languageName: node + linkType: hard + "jest-message-util@npm:30.2.0": version: 30.2.0 resolution: "jest-message-util@npm:30.2.0" @@ -35666,6 +35960,23 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" + dependencies: + "@babel/code-frame": "npm:^7.12.13" + "@jest/types": "npm:^29.6.3" + "@types/stack-utils": "npm:^2.0.0" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 + languageName: node + linkType: hard + "jest-mock@npm:30.2.0": version: 30.2.0 resolution: "jest-mock@npm:30.2.0" @@ -37865,14 +38176,7 @@ __metadata: languageName: node linkType: hard -"luxon@npm:^3.0.0, luxon@npm:^3.2.1, luxon@npm:^3.4.3, luxon@npm:^3.5.0": - version: 3.7.2 - resolution: "luxon@npm:3.7.2" - checksum: 10/b24cd205ed306ce7415991687897dcc4027921ae413c9116590bc33a95f93b86ce52cf74ba72b4f5c5ab1c10090517f54ac8edfb127c049e0bf55b90dc2260be - languageName: node - linkType: hard - -"luxon@npm:~3.5.0": +"luxon@npm:^3.0.0, luxon@npm:^3.2.1, luxon@npm:^3.4.3, luxon@npm:^3.5.0, luxon@npm:~3.5.0": version: 3.5.0 resolution: "luxon@npm:3.5.0" checksum: 10/48f86e6c1c96815139f8559456a3354a276ba79bcef0ae0d4f2172f7652f3ba2be2237b0e103b8ea0b79b47715354ac9fac04eb1db3485dcc72d5110491dd47f @@ -38870,7 +39174,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.5, micromatch@npm:^4.0.7, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.7, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -41921,13 +42225,20 @@ __metadata: languageName: node linkType: hard -"pg-connection-string@npm:^2.10.1, pg-connection-string@npm:^2.3.0, pg-connection-string@npm:^2.5.0": +"pg-connection-string@npm:^2.10.1": version: 2.10.1 resolution: "pg-connection-string@npm:2.10.1" checksum: 10/8785cfac30f64a48f47031c24d7c5c53241fabb336d5be8f30ca06af0e16aea8d9647b645b4b44ed931717a47a0afb95ddb57e60b6af103767a4d8606dec22dc languageName: node linkType: hard +"pg-connection-string@npm:^2.3.0, pg-connection-string@npm:^2.5.0": + version: 2.7.0 + resolution: "pg-connection-string@npm:2.7.0" + checksum: 10/68015a8874b7ca5dad456445e4114af3d2602bac2fdb8069315ecad0ff9660ec93259b9af7186606529ac4f6f72a06831e6f20897a689b16cc7fda7ca0e247fd + languageName: node + linkType: hard + "pg-format@npm:^1.0.4": version: 1.0.4 resolution: "pg-format@npm:1.0.4" @@ -41951,7 +42262,14 @@ __metadata: languageName: node linkType: hard -"pg-protocol@npm:*, pg-protocol@npm:^1.11.0": +"pg-protocol@npm:*": + version: 1.8.0 + resolution: "pg-protocol@npm:1.8.0" + checksum: 10/52f67d8161ae4afb1dbf96f6ad12a2ecf478dbb0b80baa239047cd562dee378961fd446f0a1cfc1fd323e052fbb3df47e886c5d9d86a2803a000d36682b29094 + languageName: node + linkType: hard + +"pg-protocol@npm:^1.11.0": version: 1.11.0 resolution: "pg-protocol@npm:1.11.0" checksum: 10/a70b1b4a3fc5b1be80dfdd65c829a149b8bd9df7488f9c47e0b51c9413aec5eb6da0a9ae9812891d74cd9f2ee90c0e391984a41b64603e7375fcbb9e07070b08 @@ -46294,13 +46612,20 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:1.8.3, shell-quote@npm:^1.6.1, shell-quote@npm:^1.7.3, shell-quote@npm:^1.8.1": +"shell-quote@npm:1.8.3": version: 1.8.3 resolution: "shell-quote@npm:1.8.3" checksum: 10/5473e354637c2bd698911224129c9a8961697486cff1fb221f234d71c153fc377674029b0223d1d3c953a68d451d79366abfe53d1a0b46ee1f28eb9ade928f4c languageName: node linkType: hard +"shell-quote@npm:^1.6.1, shell-quote@npm:^1.7.3, shell-quote@npm:^1.8.1": + version: 1.8.1 + resolution: "shell-quote@npm:1.8.1" + checksum: 10/af19ab5a1ec30cb4b2f91fd6df49a7442d5c4825a2e269b3712eded10eedd7f9efeaab96d57829880733fc55bcdd8e9b1d8589b4befb06667c731d08145e274d + languageName: node + linkType: hard + "shelljs@npm:^0.9.2": version: 0.9.2 resolution: "shelljs@npm:0.9.2" @@ -46965,7 +47290,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -47904,7 +48229,19 @@ __metadata: languageName: node linkType: hard -"tar-fs@npm:^2.0.0, tar-fs@npm:^2.1.4": +"tar-fs@npm:^2.0.0": + version: 2.1.2 + resolution: "tar-fs@npm:2.1.2" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10/623f7e8e58a43578ba7368002c3cc7e321f6d170053ac0691d95172dbc7daf5dcf4347eb061277627340870ce6cfda89f5a5d633cc274c41ae6d69f54a2374e7 + languageName: node + linkType: hard + +"tar-fs@npm:^2.1.4": version: 2.1.4 resolution: "tar-fs@npm:2.1.4" dependencies: @@ -49330,13 +49667,20 @@ __metadata: languageName: node linkType: hard -"undici@npm:^7.16.0, undici@npm:^7.2.3": +"undici@npm:^7.16.0": version: 7.19.2 resolution: "undici@npm:7.19.2" checksum: 10/26a01804402e5f09d02a5d09607e42096828698dddbcf32132dd313af4fda99f3c04ed84d884e28528f945a5b77d87df5c01ec6c1bdc918fd28878cb35f1cb4a languageName: node linkType: hard +"undici@npm:^7.2.3": + version: 7.9.0 + resolution: "undici@npm:7.9.0" + checksum: 10/fa92d3d9106612be566e79942174e00426c2e1f9a688fb86c8d1809c84a032c02fc57bd601d2051a45d94bbd312c50221644b58c8186c4f0d64f8ecc7f18b806 + languageName: node + linkType: hard + "uni-global@npm:^1.0.0": version: 1.0.0 resolution: "uni-global@npm:1.0.0" @@ -50875,9 +51219,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:*, ws@npm:^8.12.0, ws@npm:^8.13.0, ws@npm:^8.17.1, ws@npm:^8.18.0, ws@npm:^8.18.2, ws@npm:^8.18.3, ws@npm:^8.8.0": - version: 8.19.0 - resolution: "ws@npm:8.19.0" +"ws@npm:*, ws@npm:^8.12.0, ws@npm:^8.13.0, ws@npm:^8.17.1, ws@npm:^8.18.0, ws@npm:^8.8.0": + version: 8.18.3 + resolution: "ws@npm:8.18.3" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -50886,7 +51230,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b + checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 languageName: node linkType: hard @@ -50920,6 +51264,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.2, ws@npm:^8.18.3": + version: 8.19.0 + resolution: "ws@npm:8.19.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b + languageName: node + linkType: hard + "wsl-utils@npm:^0.1.0": version: 0.1.0 resolution: "wsl-utils@npm:0.1.0"