From 104ca74ec27f3c6d59c24ed39b688a77d0c3bf36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Fri, 5 Sep 2025 14:11:36 +0200 Subject: [PATCH 1/7] Add multiget to usersettings to batch get()s in one call and cache the result for a short period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .changeset/stupid-flies-shake.md | 7 + plugins/user-settings-backend/package.json | 1 + .../src/service/router.ts | 62 ++- plugins/user-settings-common/package.json | 4 + plugins/user-settings-common/report.api.md | 34 ++ plugins/user-settings-common/src/index.ts | 3 + plugins/user-settings-common/src/keys.ts | 26 + plugins/user-settings-common/src/types.ts | 39 ++ plugins/user-settings/package.json | 2 + .../src/apis/StorageApi/CacheMap.ts | 56 +++ .../StorageApi/UserSettingsStorage.test.ts | 234 ++++++--- .../apis/StorageApi/UserSettingsStorage.ts | 117 ++++- yarn.lock | 465 ++++++++++++++++-- 13 files changed, 917 insertions(+), 133 deletions(-) create mode 100644 .changeset/stupid-flies-shake.md create mode 100644 plugins/user-settings-common/src/keys.ts create mode 100644 plugins/user-settings/src/apis/StorageApi/CacheMap.ts 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" From f79e0d9d77ade1a9ea53ce2e7690f9905734707b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Mon, 15 Sep 2025 09:27:25 +0200 Subject: [PATCH 2/7] Use p-limit instead of already for promise concurrency limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- plugins/user-settings-backend/package.json | 3 +- .../src/service/router.test.ts | 143 ++++++++++++++++++ .../src/service/router.ts | 50 +++--- plugins/user-settings/package.json | 1 - .../StorageApi/UserSettingsStorage.test.ts | 10 +- yarn.lock | 2 +- 6 files changed, 177 insertions(+), 32 deletions(-) diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index 917fd775b1..c33b2c1a96 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -58,7 +58,8 @@ "already": "^2.2.1", "express": "^4.22.0", "express-promise-router": "^4.1.0", - "knex": "^3.0.0" + "knex": "^3.0.0", + "p-limit": "^3.1.0" }, "devDependencies": { "@backstage/backend-defaults": "workspace:^", diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index 01277a9fd3..1f34806313 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -18,12 +18,14 @@ import express from 'express'; import request from 'supertest'; import { UserSettingsStore } from '../database/UserSettingsStore'; import { createRouter } from './router'; +import { NotFoundError } from '@backstage/errors'; import { SignalsService } from '@backstage/plugin-signals-node'; import { mockCredentials, mockServices, mockErrorHandler, } from '@backstage/backend-test-utils'; +import { stringifyDataLoaderKey } from '@backstage/plugin-user-settings-common'; describe('createRouter', () => { const userSettingsStore: jest.Mocked = { @@ -108,6 +110,147 @@ describe('createRouter', () => { }); }); + describe('GET /multi', () => { + it('returns single value', async () => { + const values = { 'key-1': 'a' }; + + userSettingsStore.get.mockImplementation(async ({ bucket, key }) => { + if (key in values) { + return { + bucket: 'my-bucket', + key, + value: values[key as keyof typeof values], + }; + } + throw new NotFoundError( + `Unable to find '${key}' in bucket '${bucket}'`, + ); + }); + + const url = new URLSearchParams(); + for (const key of Object.keys(values)) { + url.append('items', stringifyDataLoaderKey('my-bucket', key)); + } + const responses = await request(app).get(`/multi?${url.toString()}`); + + expect(responses.status).toEqual(200); + expect(responses.body).toEqual([ + { bucket: 'my-bucket', key: 'key-1', value: 'a' }, + ]); + + expect(userSettingsStore.get).toHaveBeenCalledTimes(1); + expect(userSettingsStore.get).toHaveBeenCalledWith({ + userEntityRef: mockUserRef, + bucket: 'my-bucket', + key: 'key-1', + }); + }); + + it('returns single missing', async () => { + const values = { 'key-1': 'a' }; + + userSettingsStore.get.mockImplementation(async ({ bucket, key }) => { + if (key in values) { + return { + bucket: 'my-bucket', + key, + value: values[key as keyof typeof values], + }; + } + throw new NotFoundError( + `Unable to find '${key}' in bucket '${bucket}'`, + ); + }); + + const url = new URLSearchParams(); + url.append('items', stringifyDataLoaderKey('my-bucket', 'missing-key')); + const responses = await request(app).get(`/multi?${url.toString()}`); + + expect(responses.status).toEqual(200); + expect(responses.body).toEqual([ + { + bucket: 'my-bucket', + key: 'missing-key', + error: { + name: 'NotFoundError', + message: expect.stringContaining('missing-key'), + }, + }, + ]); + + expect(userSettingsStore.get).toHaveBeenCalledTimes(1); + expect(userSettingsStore.get).toHaveBeenCalledWith({ + userEntityRef: mockUserRef, + bucket: 'my-bucket', + key: 'missing-key', + }); + }); + + it('returns existing and missing mixed', async () => { + const values = { + 'key-1': 'a', + 'key-2': 'b', + }; + + userSettingsStore.get.mockImplementation(async ({ bucket, key }) => { + if (key in values) { + return { + bucket: 'my-bucket', + key, + value: values[key as keyof typeof values], + }; + } + throw new NotFoundError( + `Unable to find '${key}' in bucket '${bucket}'`, + ); + }); + + const url = new URLSearchParams(); + for (const key of Object.keys(values)) { + url.append('items', stringifyDataLoaderKey('my-bucket', key)); + } + url.append('items', stringifyDataLoaderKey('my-bucket', 'missing-key')); + const responses = await request(app).get(`/multi?${url.toString()}`); + + expect(responses.status).toEqual(200); + expect(responses.body).toEqual([ + { bucket: 'my-bucket', key: 'key-1', value: 'a' }, + { bucket: 'my-bucket', key: 'key-2', value: 'b' }, + { + bucket: 'my-bucket', + key: 'missing-key', + error: { + name: 'NotFoundError', + message: expect.stringContaining('missing-key'), + }, + }, + ]); + + expect(userSettingsStore.get).toHaveBeenCalledTimes(3); + for (const key of Object.keys(values)) { + expect(userSettingsStore.get).toHaveBeenCalledWith({ + userEntityRef: mockUserRef, + bucket: 'my-bucket', + key, + }); + } + expect(userSettingsStore.get).toHaveBeenCalledWith({ + userEntityRef: mockUserRef, + bucket: 'my-bucket', + key: 'missing-key', + }); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app) + .get('/buckets/my-bucket/keys/my-key') + .set('Authorization', mockCredentials.none.header()); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.get).not.toHaveBeenCalled(); + }); + }); + describe('DELETE /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { userSettingsStore.delete.mockResolvedValue(); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index 9e86c062a9..8a999fe0c2 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -15,9 +15,9 @@ */ import { InputError, serializeError } from '@backstage/errors'; -import { map } from 'already'; import express, { Request } from 'express'; import Router from 'express-promise-router'; +import pLimit from 'p-limit'; import { UserSettingsStore } from '../database/UserSettingsStore'; import { SignalsService } from '@backstage/plugin-signals-node'; import { @@ -69,30 +69,32 @@ export async function createRouter(options: { 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 }; - } + const limit = pLimit(10); - return { - bucket, - key, - error: { name: 'Error', message: 'Unknown error' }, - }; - } - }, + const userSettings = await Promise.all( + bucketsAndKeys.map(({ bucket, key }) => + limit(async (): 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); diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 4853953f64..cb8777b5aa 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -83,7 +83,6 @@ "@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/UserSettingsStorage.test.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts index 22f7f75ee3..8ec9439242 100644 --- a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts @@ -27,7 +27,7 @@ import { } from '@backstage/test-utils'; import { NotFoundError } from '@backstage/errors'; import { parseDataLoaderKey } from '@backstage/plugin-user-settings-common'; -import { defer } from 'already'; +import { createDeferred } from '@backstage/types'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { UserSettingsStorage } from './UserSettingsStorage'; @@ -174,7 +174,7 @@ describe('Persistent Storage API', () => { const selectedKeyNextHandler = jest.fn(); const mockData = { hello: 'im a great new value' }; - const serverCall = defer(undefined); + const serverCall = createDeferred(); server.use( rest.put( @@ -216,7 +216,7 @@ describe('Persistent Storage API', () => { value: mockData, }); - await serverCall.promise; + await serverCall; }); it('should subscribe to key changes when deleting a value', async () => { @@ -225,7 +225,7 @@ describe('Persistent Storage API', () => { const wrongKeyNextHandler = jest.fn(); const selectedKeyNextHandler = jest.fn(); - const serverCall = defer(undefined); + const serverCall = createDeferred(); server.use( rest.delete( @@ -263,7 +263,7 @@ describe('Persistent Storage API', () => { value: undefined, }); - await serverCall.promise; + await serverCall; }); it('should not clash with other namespaces when creating buckets', async () => { diff --git a/yarn.lock b/yarn.lock index 4704f1f38a..0d3f14ac7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7827,6 +7827,7 @@ __metadata: express: "npm:^4.22.0" express-promise-router: "npm:^4.1.0" knex: "npm:^3.0.0" + p-limit: "npm:^3.1.0" supertest: "npm:^7.0.0" languageName: unknown linkType: soft @@ -7868,7 +7869,6 @@ __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" From 233bb3086c9f006b4b58474010ea0c3d4dd6584d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Mon, 29 Sep 2025 14:42:57 +0200 Subject: [PATCH 3/7] Changed endpoint name, GET->POST, request/response signature, and perform batch queries against db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- plugins/user-settings-backend/package.json | 3 +- .../DatabaseUserSettingsStore.test.ts | 75 ++++++++ .../src/database/DatabaseUserSettingsStore.ts | 75 ++++++++ .../src/database/UserSettingsStore.ts | 10 ++ .../src/service/router.test.ts | 167 ++++++++---------- .../src/service/router.ts | 66 ++----- plugins/user-settings-common/package.json | 1 - plugins/user-settings-common/report.api.md | 33 +--- plugins/user-settings-common/src/index.ts | 3 - plugins/user-settings-common/src/keys.ts | 26 --- plugins/user-settings-common/src/types.ts | 34 +--- .../StorageApi/UserSettingsStorage.test.ts | 54 +++--- .../apis/StorageApi/UserSettingsStorage.ts | 120 +++++++------ 13 files changed, 343 insertions(+), 324 deletions(-) delete mode 100644 plugins/user-settings-common/src/keys.ts diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index c33b2c1a96..261335b040 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -59,7 +59,8 @@ "express": "^4.22.0", "express-promise-router": "^4.1.0", "knex": "^3.0.0", - "p-limit": "^3.1.0" + "p-limit": "^3.1.0", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/backend-defaults": "workspace:^", diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts index 2d9ea5b23e..5fb2dde23f 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts @@ -107,6 +107,81 @@ describe.each(databases.eachSupportedId())( }); }); + describe('multiget', () => { + it('should return empty', async () => { + await expect( + storage.multiget({ + userEntityRef: 'user-1', + items: [ + { + bucket: 'bucket-c', + key: 'key-c', + }, + ], + }), + ).resolves.toEqual([null]); + }); + + it('should handle large inputs and return existing values', async () => { + // Create 30 buckets with 300 keys in each. + const BUCKETS = 30; + const KEYS_PER_BUCKET = 300; + + const buckets = Array.from(Array(BUCKETS)).map( + (_, i) => `mget-bucket-${i}`, + ); + + const items = buckets.flatMap(bucket => { + const keys = Array.from(Array(KEYS_PER_BUCKET)).map( + (_, i) => `mget-key-${i}`, + ); + return keys.map(key => ({ bucket, key })); + }); + + const chunkify = (keys: Array, size: number): T[][] => { + const chunks: T[][] = []; + for (let i = 0; i < keys.length; i += size) { + chunks.push(keys.slice(i, i + size)); + } + return chunks; + }; + + const valueOfKey = (bucket: string, key: string) => ({ + theValue: `Value of ${bucket} / ${key}`, + }); + + const chunkedItems = chunkify(items, 100); + for (const chunk of chunkedItems) { + await insert( + chunk.map(({ bucket, key }) => ({ + user_entity_ref: 'user-1', + bucket, + key, + value: JSON.stringify(valueOfKey(bucket, key)), + })), + ); + } + + const result = await storage.multiget({ + userEntityRef: 'user-1', + // Include a missing key which shouldn't exist in the result + items: [...items, { bucket: 'missing', key: 'missing' }], + }); + expect(result).toEqual([ + ...items.map(({ bucket, key }) => ({ + value: valueOfKey(bucket, key), + })), + null, // The missing key + ]); + + const rows = await knex('user_settings') + .where('user_entity_ref', 'user-1') + .count<{ count: string }[]>('* as count'); + const savedRows = Number(rows[0].count); + expect(savedRows).toEqual(BUCKETS * KEYS_PER_BUCKET); + }); + }); + describe('set', () => { it('should insert a new setting', async () => { await storage.set({ diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts index 8caa62c4cd..1d53d87162 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -21,6 +21,7 @@ import { import { NotFoundError } from '@backstage/errors'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; +import pLimit from 'p-limit'; import { UserSettingsStore, type UserSetting } from './UserSettingsStore'; const migrationsDir = resolvePackagePath( @@ -91,6 +92,80 @@ export class DatabaseUserSettingsStore implements UserSettingsStore { }; } + async multiget(options: { + userEntityRef: string; + items: Array<{ bucket: string; key: string }>; + }): Promise<({ value: JsonValue } | null)[]> { + if (options.items.length === 0) { + return []; + } + + // Split the items into a map of bucket -> keys + const bucketMap = new Map>(); + for (const item of options.items) { + let keys = bucketMap.get(item.bucket); + if (!keys) { + keys = new Set(); + bucketMap.set(item.bucket, keys); + } + keys.add(item.key); + } + + const dbLimit = pLimit(10); + + // Chunks the keys per bucket to avoid hitting SQL parameter limits + const chunkKeys = (keys: Array, size: number): string[][] => { + const chunks = []; + for (let i = 0; i < keys.length; i += size) { + chunks.push(keys.slice(i, i + size)); + } + return chunks; + }; + + // Store the database content into a map of bucket -> {key -> value} + const resultsMap = new Map>(); + + await Promise.all( + bucketMap.keys().map(bucket => + dbLimit(async (): Promise => { + const keyMap = new Map(); + resultsMap.set(bucket, keyMap); + + const keyChunks = chunkKeys( + Array.from(bucketMap.get(bucket) || []), + 100, + ); + + for (const keys of keyChunks) { + const rows = await this.db('user_settings') + .where({ + user_entity_ref: options.userEntityRef, + bucket, + }) + .whereIn('key', keys) + .select(['bucket', 'key', 'value']); + + for (const row of rows) { + keyMap.set(row.key, JSON.parse(row.value)); + } + } + }), + ), + ); + + // For each exact bucket/key requested, return either the value or null if + // not found + return options.items.map(({ bucket, key }) => { + const value = resultsMap.get(bucket)?.get(key); + + if (typeof value === 'undefined') { + return null; + } + + return { value }; + }); + } + async set(options: { userEntityRef: string; bucket: string; diff --git a/plugins/user-settings-backend/src/database/UserSettingsStore.ts b/plugins/user-settings-backend/src/database/UserSettingsStore.ts index 289c37523e..9c0a5ebf5b 100644 --- a/plugins/user-settings-backend/src/database/UserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/UserSettingsStore.ts @@ -35,6 +35,16 @@ export interface UserSettingsStore { key: string; }): Promise; + /** + * Get multiple user settings at once. The result is an array corresponding + * index by index to the elements of the input, where the elements are either + * `null` (not found), or an object `{ value: JsonValue }`. + */ + multiget(options: { + userEntityRef: string; + items: Array<{ bucket: string; key: string }>; + }): Promise<({ value: JsonValue } | null)[]>; + set(options: { userEntityRef: string; bucket: string; diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index 1f34806313..3503ba3c80 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -18,17 +18,17 @@ import express from 'express'; import request from 'supertest'; import { UserSettingsStore } from '../database/UserSettingsStore'; import { createRouter } from './router'; -import { NotFoundError } from '@backstage/errors'; import { SignalsService } from '@backstage/plugin-signals-node'; import { mockCredentials, mockServices, mockErrorHandler, } from '@backstage/backend-test-utils'; -import { stringifyDataLoaderKey } from '@backstage/plugin-user-settings-common'; +import { JsonValue } from '@backstage/types'; describe('createRouter', () => { const userSettingsStore: jest.Mocked = { + multiget: jest.fn(), get: jest.fn(), set: jest.fn(), delete: jest.fn(), @@ -111,78 +111,72 @@ describe('createRouter', () => { }); describe('GET /multi', () => { + const mockMultiget = (store: Record>) => { + userSettingsStore.multiget.mockImplementation(async ({ items }) => { + return items.map(({ bucket, key }) => { + const value = store[bucket]?.[key]; + if (typeof value !== 'undefined') { + return { value }; + } + return null; + }); + }); + }; + it('returns single value', async () => { const values = { 'key-1': 'a' }; - userSettingsStore.get.mockImplementation(async ({ bucket, key }) => { - if (key in values) { - return { + mockMultiget({ 'my-bucket': values }); + + const responses = await request(app) + .post('/multiget') + .send({ + items: Object.keys(values).map(key => ({ bucket: 'my-bucket', key, - value: values[key as keyof typeof values], - }; - } - throw new NotFoundError( - `Unable to find '${key}' in bucket '${bucket}'`, - ); - }); - - const url = new URLSearchParams(); - for (const key of Object.keys(values)) { - url.append('items', stringifyDataLoaderKey('my-bucket', key)); - } - const responses = await request(app).get(`/multi?${url.toString()}`); + })), + }); expect(responses.status).toEqual(200); - expect(responses.body).toEqual([ - { bucket: 'my-bucket', key: 'key-1', value: 'a' }, - ]); + expect(responses.body).toEqual({ items: [{ value: 'a' }] }); - expect(userSettingsStore.get).toHaveBeenCalledTimes(1); - expect(userSettingsStore.get).toHaveBeenCalledWith({ + expect(userSettingsStore.multiget).toHaveBeenCalledTimes(1); + expect(userSettingsStore.multiget).toHaveBeenCalledWith({ userEntityRef: mockUserRef, - bucket: 'my-bucket', - key: 'key-1', + items: [ + { + bucket: 'my-bucket', + key: 'key-1', + }, + ], }); }); it('returns single missing', async () => { const values = { 'key-1': 'a' }; - userSettingsStore.get.mockImplementation(async ({ bucket, key }) => { - if (key in values) { - return { - bucket: 'my-bucket', - key, - value: values[key as keyof typeof values], - }; - } - throw new NotFoundError( - `Unable to find '${key}' in bucket '${bucket}'`, - ); - }); + mockMultiget({ 'my-bucket': values }); - const url = new URLSearchParams(); - url.append('items', stringifyDataLoaderKey('my-bucket', 'missing-key')); - const responses = await request(app).get(`/multi?${url.toString()}`); + const responses = await request(app) + .post('/multiget') + .send({ + items: [{ bucket: 'my-bucket', key: 'missing-key' }], + }); expect(responses.status).toEqual(200); - expect(responses.body).toEqual([ - { - bucket: 'my-bucket', - key: 'missing-key', - error: { - name: 'NotFoundError', - message: expect.stringContaining('missing-key'), - }, - }, - ]); + expect(responses.body).toEqual({ + items: [null], + }); - expect(userSettingsStore.get).toHaveBeenCalledTimes(1); - expect(userSettingsStore.get).toHaveBeenCalledWith({ + expect(userSettingsStore.multiget).toHaveBeenCalledTimes(1); + expect(userSettingsStore.multiget).toHaveBeenCalledWith({ userEntityRef: mockUserRef, - bucket: 'my-bucket', - key: 'missing-key', + items: [ + { + bucket: 'my-bucket', + key: 'missing-key', + }, + ], }); }); @@ -192,52 +186,35 @@ describe('createRouter', () => { 'key-2': 'b', }; - userSettingsStore.get.mockImplementation(async ({ bucket, key }) => { - if (key in values) { - return { - bucket: 'my-bucket', - key, - value: values[key as keyof typeof values], - }; - } - throw new NotFoundError( - `Unable to find '${key}' in bucket '${bucket}'`, - ); - }); + mockMultiget({ 'my-bucket': values }); - const url = new URLSearchParams(); - for (const key of Object.keys(values)) { - url.append('items', stringifyDataLoaderKey('my-bucket', key)); - } - url.append('items', stringifyDataLoaderKey('my-bucket', 'missing-key')); - const responses = await request(app).get(`/multi?${url.toString()}`); + const responses = await request(app) + .post('/multiget') + .send({ + items: [ + ...Object.keys(values).map(key => ({ bucket: 'my-bucket', key })), + { bucket: 'my-bucket', key: 'missing-key' }, + ], + }); expect(responses.status).toEqual(200); - expect(responses.body).toEqual([ - { bucket: 'my-bucket', key: 'key-1', value: 'a' }, - { bucket: 'my-bucket', key: 'key-2', value: 'b' }, - { - bucket: 'my-bucket', - key: 'missing-key', - error: { - name: 'NotFoundError', - message: expect.stringContaining('missing-key'), - }, - }, - ]); + expect(responses.body).toEqual({ + items: [{ value: 'a' }, { value: 'b' }, null], + }); - expect(userSettingsStore.get).toHaveBeenCalledTimes(3); - for (const key of Object.keys(values)) { - expect(userSettingsStore.get).toHaveBeenCalledWith({ - userEntityRef: mockUserRef, - bucket: 'my-bucket', - key, - }); - } - expect(userSettingsStore.get).toHaveBeenCalledWith({ + expect(userSettingsStore.multiget).toHaveBeenCalledTimes(1); + expect(userSettingsStore.multiget).toHaveBeenCalledWith({ userEntityRef: mockUserRef, - bucket: 'my-bucket', - key: 'missing-key', + items: [ + ...Object.keys(values).map(key => ({ + bucket: 'my-bucket', + key, + })), + { + bucket: 'my-bucket', + key: 'missing-key', + }, + ], }); }); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index 8a999fe0c2..c9971bbb4d 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -14,16 +14,15 @@ * limitations under the License. */ -import { InputError, serializeError } from '@backstage/errors'; +import { InputError } from '@backstage/errors'; import express, { Request } from 'express'; import Router from 'express-promise-router'; -import pLimit from 'p-limit'; +import { z } from 'zod'; import { UserSettingsStore } from '../database/UserSettingsStore'; import { SignalsService } from '@backstage/plugin-signals-node'; import { - MultiUserSetting, + MultiGetResponse, UserSettingsSignal, - parseDataLoaderKey, } from '@backstage/plugin-user-settings-common'; import { HttpAuthService } from '@backstage/backend-plugin-api'; @@ -35,6 +34,10 @@ export async function createRouter(options: { const router = Router(); router.use(express.json()); + const multiGetRequestSchema = z.object({ + items: z.array(z.object({ bucket: z.string(), key: z.string() })), + }); + /** * Helper method to extract the userEntityRef from the request. */ @@ -46,58 +49,17 @@ export async function createRouter(options: { }; // get multiple values - router.get('/multi', async (req, res) => { + router.post('/multiget', async (req, res) => { const userEntityRef = await getUserEntityRef(req); - const bucketsAndKeys: ReturnType[] = []; + const bucketsAndKeys = multiGetRequestSchema.parse(req.body).items; - 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 items = await options.userSettingsStore.multiget({ + userEntityRef, + items: bucketsAndKeys, + }); - const limit = pLimit(10); - - const userSettings = await Promise.all( - bucketsAndKeys.map(({ bucket, key }) => - limit(async (): 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); + res.json({ items } satisfies MultiGetResponse); }); // get a single value diff --git a/plugins/user-settings-common/package.json b/plugins/user-settings-common/package.json index 0002355afb..92a5b84ecd 100644 --- a/plugins/user-settings-common/package.json +++ b/plugins/user-settings-common/package.json @@ -38,7 +38,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^" }, "devDependencies": { diff --git a/plugins/user-settings-common/report.api.md b/plugins/user-settings-common/report.api.md index 03903d8107..42b9bd3f04 100644 --- a/plugins/user-settings-common/report.api.md +++ b/plugins/user-settings-common/report.api.md @@ -4,39 +4,14 @@ ```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; +export type MultiGetResponse = { + items: ({ + value: JsonValue; + } | null)[]; }; -// @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 43f85ada2b..5d542de408 100644 --- a/plugins/user-settings-common/src/index.ts +++ b/plugins/user-settings-common/src/index.ts @@ -15,6 +15,3 @@ */ 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 deleted file mode 100644 index 4a319dcd02..0000000000 --- a/plugins/user-settings-common/src/keys.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 43fcef8a7d..437d0c8e1c 100644 --- a/plugins/user-settings-common/src/types.ts +++ b/plugins/user-settings-common/src/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type { SerializedError } from '@backstage/errors'; import type { JsonValue } from '@backstage/types'; /** @public */ @@ -24,37 +23,8 @@ export type UserSettingsSignal = { }; /** - * A failed fetch of a user setting in a bucket + * Response type from /multiget * * @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; -} +export type MultiGetResponse = { items: ({ value: JsonValue } | null)[] }; diff --git a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts index 8ec9439242..0dd8aa4c36 100644 --- a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts @@ -25,8 +25,6 @@ import { mockApis, registerMswTestHooks, } from '@backstage/test-utils'; -import { NotFoundError } from '@backstage/errors'; -import { parseDataLoaderKey } from '@backstage/plugin-user-settings-common'; import { createDeferred } from '@backstage/types'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -187,7 +185,7 @@ describe('Persistent Storage API', () => { return res(ctx.json(data)); }, ), - rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => { + rest.post(`${mockBaseUrl}/multiget`, async (_req, res, ctx) => { serverCall.resolve(); return res(ctx.json([])); }), @@ -234,7 +232,7 @@ describe('Persistent Storage API', () => { return res(ctx.status(204)); }, ), - rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => { + rest.post(`${mockBaseUrl}/multiget`, async (_req, res, ctx) => { serverCall.resolve(); return res(ctx.json([])); }), @@ -283,10 +281,9 @@ describe('Persistent Storage API', () => { return res(ctx.json({ value })); }, ), - rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => { - const { bucket, key } = parseDataLoaderKey( - req.url.searchParams.get('items') as string, - ); + rest.post(`${mockBaseUrl}/multiget`, async (req, res, ctx) => { + const payload = await req.json(); + const { bucket, key } = payload.items[0]; expect(bucket).toEqual('clash.profile/something'); expect(key).toEqual('deep/test2'); @@ -333,12 +330,11 @@ describe('Persistent Storage API', () => { }); server.use( - 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'); + rest.post(`${mockBaseUrl}/multiget`, async (req, res, ctx) => { + const payload = await req.json(); + expect(payload).toEqual({ + items: [{ bucket: 'Test.Mock.Thing', key: 'key' }], + }); return res(ctx.text('{ invalid: json string }')); }), ); @@ -369,8 +365,8 @@ describe('Persistent Storage API', () => { const data = { foo: 'bar', baz: [{ foo: 'bar' }] }; server.use( - rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => { - return res(ctx.json([{ bucket: 'freeze', key: 'key', value: data }])); + rest.post(`${mockBaseUrl}/multiget`, async (_req, res, ctx) => { + return res(ctx.json({ items: [{ value: data }] })); }), ); @@ -415,21 +411,21 @@ describe('Persistent Storage API', () => { let serverCalls = 0; server.use( - rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => { + rest.post(`${mockBaseUrl}/multiget`, 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() }; - }); + const payload = (await req.json()) as { + items: { bucket: string; key: string }[]; + }; + const result = payload.items.map(item => { + if (item.key === 'key1') { + return { value: data1 }; + } else if (item.key === 'key2') { + return { value: data2 }; + } + return null; + }); - return res(ctx.json(result)); + return res(ctx.json({ items: result })); }), ); diff --git a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts index 141aa0aa1a..9a5a0f22ed 100644 --- a/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts @@ -23,15 +23,12 @@ import { StorageApi, StorageValueSnapshot, } from '@backstage/core-plugin-api'; -import { deserializeError, ResponseError } from '@backstage/errors'; +import { ResponseError } from '@backstage/errors'; import { JsonValue, Observable } from '@backstage/types'; import { SignalApi, SignalSubscriber } from '@backstage/plugin-signals-react'; import ObservableImpl from 'zen-observable'; import { - isMultiUserSettingError, - MultiUserSetting, - parseDataLoaderKey, - stringifyDataLoaderKey, + MultiGetResponse, UserSettingsSignal, } from '@backstage/plugin-user-settings-common'; import DataLoader from 'dataloader'; @@ -47,6 +44,12 @@ const buckets = new Map(); const DATALOADER_CACHE_TTL_MS = 2 * 1000; // 2 seconds cache const DATALOADER_WINDOW_MS = 10; // 10 ms +type DataLoaderType = DataLoader< + { bucket: string; key: string }, + StorageValueSnapshot, + string +>; + /** * An implementation of the storage API, that uses the user-settings backend to * persist the data in the DB. @@ -70,7 +73,7 @@ export class UserSettingsStorage implements StorageApi { private readonly identityApi: IdentityApi; private readonly fallback: WebStorage; private readonly signalApi?: SignalApi; - private readonly userSettingsLoader: DataLoader; + private readonly userSettingsLoader: DataLoaderType; private constructor( namespace: string, @@ -80,7 +83,7 @@ export class UserSettingsStorage implements StorageApi { identityApi: IdentityApi, fallback: WebStorage, signalApi?: SignalApi, - userSettingsLoader?: DataLoader, + userSettingsLoader?: DataLoaderType, ) { this.namespace = namespace; this.fetchApi = fetchApi; @@ -92,24 +95,32 @@ export class UserSettingsStorage implements StorageApi { this.userSettingsLoader = userSettingsLoader ?? - new DataLoader( + new DataLoader( async bucketAndKeyList => this.getMulti(bucketAndKeyList), { name: 'UserSettingsStorage.userSettingsLoader', - cacheMap: new CacheMap>( - DATALOADER_CACHE_TTL_MS, - ), + cacheMap: new CacheMap< + string, + Promise> + >(DATALOADER_CACHE_TTL_MS), + cacheKeyFn: bucketAndKey => this.stringifyDataLoaderKey(bucketAndKey), maxBatchSize: 100, batchScheduleFn: cb => setTimeout(cb, DATALOADER_WINDOW_MS), }, ); } - private stringifyDataLoaderKey(key: string) { - return stringifyDataLoaderKey(this.namespace, key); + private stringifyDataLoaderKey({ + bucket, + key, + }: { + bucket: string; + key: string; + }) { + return `${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`; } private clearCacheKey(key: string) { - this.userSettingsLoader.clear(this.stringifyDataLoaderKey(key)); + this.userSettingsLoader.clear({ bucket: this.namespace, key }); } static create(options: { @@ -197,11 +208,14 @@ export class UserSettingsStorage implements StorageApi { const { value } = await response.json(); - this.userSettingsLoader.prime(this.stringifyDataLoaderKey(key), { - key, - presence: 'present', - value, - }); + this.userSettingsLoader.prime( + { bucket: this.namespace, key }, + { + key, + presence: 'present', + value, + }, + ); this.notifyChanges({ key, value, presence: 'present' }); } @@ -219,7 +233,7 @@ export class UserSettingsStorage implements StorageApi { const updateSnapshot = () => { Promise.resolve() .then(() => - this.userSettingsLoader.load(this.stringifyDataLoaderKey(key)), + this.userSettingsLoader.load({ bucket: this.namespace, key }), ) .then(snapshot => subscriber.next(snapshot)) .catch(error => this.errorApi.post(error)); @@ -256,53 +270,47 @@ export class UserSettingsStorage implements StorageApi { } private async getMulti( - bucketAndKeyList: readonly string[], + bucketAndKeyList: readonly { bucket: string; key: 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 bucketAndKeyList.map(bucketAndKey => - this.fallback.snapshot(parseDataLoaderKey(bucketAndKey).key), + this.fallback.snapshot(bucketAndKey.key), ); } - 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 bucketAndKeyList.map(bucketAndKey => ({ - key: parseDataLoaderKey(bucketAndKey).key, - presence: 'absent', - })); - } - - if (!response.ok) { - throw await ResponseError.fromResponse(response); - } - try { - const values = await response.json(); + const baseUrl = await this.discoveryApi.getBaseUrl('user-settings'); + const response = await this.fetchApi.fetch(`${baseUrl}/multiget`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ items: bucketAndKeyList }), + }); - 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); + if (response.status === 404) { + return bucketAndKeyList.map(bucketAndKey => ({ + key: bucketAndKey.key, + presence: 'absent', + })); + } + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + const { items: values } = (await response.json()) as MultiGetResponse; + + return bucketAndKeyList.map( + ({ key }, i): StorageValueSnapshot => { + if (!values[i]) { + return { key, presence: 'absent' }; } return { - key: setting.key, + key, presence: 'present', - value: JSON.parse(JSON.stringify(setting.value), (_key, val) => { + value: JSON.parse(JSON.stringify(values[i].value), (_key, val) => { if (typeof val === 'object' && val !== null) { Object.freeze(val); } @@ -311,10 +319,10 @@ export class UserSettingsStorage implements StorageApi { }; }, ); - } catch { - // If the value is not valid JSON, we return an unknown presence. This should never happen + } catch (e) { + this.errorApi.post(new Error(`Failed to fetch user settings, ${e}`)); return bucketAndKeyList.map(bucketAndKey => ({ - key: parseDataLoaderKey(bucketAndKey).key, + key: bucketAndKey.key, presence: 'absent', })); } From f4ddd8f3534ad4dae67bc6e2c4af1255d6ce839b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Mon, 29 Sep 2025 14:50:24 +0200 Subject: [PATCH 4/7] yarn.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 0d3f14ac7b..dcd2bcdb48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7829,6 +7829,7 @@ __metadata: knex: "npm:^3.0.0" p-limit: "npm:^3.1.0" supertest: "npm:^7.0.0" + zod: "npm:^3.22.4" languageName: unknown linkType: soft @@ -7837,7 +7838,6 @@ __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 From c406c94b9982a665b2eb71bb3ff9911ed987f7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Mon, 29 Sep 2025 15:06:23 +0200 Subject: [PATCH 5/7] Handle Node.js 20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .../src/database/DatabaseUserSettingsStore.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts index 1d53d87162..7aa05d9803 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -126,15 +126,12 @@ export class DatabaseUserSettingsStore implements UserSettingsStore { const resultsMap = new Map>(); await Promise.all( - bucketMap.keys().map(bucket => + Array.from(bucketMap.entries()).map(([bucket, keySet]) => dbLimit(async (): Promise => { const keyMap = new Map(); resultsMap.set(bucket, keyMap); - const keyChunks = chunkKeys( - Array.from(bucketMap.get(bucket) || []), - 100, - ); + const keyChunks = chunkKeys(Array.from(keySet), 100); for (const keys of keyChunks) { const rows = await this.db('user_settings') From 5a149b0bcfd789d1f2218a275e647c1187283c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Tue, 3 Feb 2026 16:22:39 +0100 Subject: [PATCH 6/7] Limit db connections globally for the process, not per call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .../src/database/DatabaseUserSettingsStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts index 7aa05d9803..c631227ae3 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -29,6 +29,8 @@ const migrationsDir = resolvePackagePath( 'migrations', ); +const dbLimit = pLimit(10); + /** * @public */ @@ -111,8 +113,6 @@ export class DatabaseUserSettingsStore implements UserSettingsStore { keys.add(item.key); } - const dbLimit = pLimit(10); - // Chunks the keys per bucket to avoid hitting SQL parameter limits const chunkKeys = (keys: Array, size: number): string[][] => { const chunks = []; From 8e8fdd447a8f2194ef87c96f7bfb0a397290a8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Tue, 3 Feb 2026 16:23:07 +0100 Subject: [PATCH 7/7] Bump zod to 3.25.76 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- plugins/user-settings-backend/package.json | 2 +- yarn.lock | 455 +++------------------ 2 files changed, 55 insertions(+), 402 deletions(-) diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index 261335b040..e1f91a0abe 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -60,7 +60,7 @@ "express-promise-router": "^4.1.0", "knex": "^3.0.0", "p-limit": "^3.1.0", - "zod": "^3.22.4" + "zod": "^3.25.76" }, "devDependencies": { "@backstage/backend-defaults": "workspace:^", diff --git a/yarn.lock b/yarn.lock index dcd2bcdb48..7bdb198d25 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.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": +"@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": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" dependencies: @@ -2440,13 +2440,6 @@ __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" @@ -2622,18 +2615,7 @@ __metadata: languageName: node linkType: hard -"@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": +"@babel/plugin-syntax-jsx@npm:^7.24.7, @babel/plugin-syntax-jsx@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-syntax-jsx@npm:7.27.1" dependencies: @@ -2732,18 +2714,7 @@ __metadata: languageName: node linkType: hard -"@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": +"@babel/plugin-syntax-typescript@npm:^7.24.7, @babel/plugin-syntax-typescript@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-syntax-typescript@npm:7.27.1" dependencies: @@ -2928,17 +2899,7 @@ __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.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": +"@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": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -7829,7 +7790,7 @@ __metadata: knex: "npm:^3.0.0" p-limit: "npm:^3.1.0" supertest: "npm:^7.0.0" - zod: "npm:^3.22.4" + zod: "npm:^3.25.76" languageName: unknown linkType: soft @@ -10352,15 +10313,6 @@ __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" @@ -14009,20 +13961,13 @@ __metadata: languageName: node linkType: hard -"@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": +"@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": 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" @@ -17684,7 +17629,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^13.0.0": +"@sinonjs/fake-timers@npm:^13.0.0, @sinonjs/fake-timers@npm:^13.0.1": version: 13.0.5 resolution: "@sinonjs/fake-timers@npm:13.0.5" dependencies: @@ -17693,15 +17638,6 @@ __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" @@ -17789,14 +17725,7 @@ __metadata: languageName: node linkType: hard -"@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": +"@slack/types@npm:^2.11.0, @slack/types@npm:^2.13.0, @slack/types@npm:^2.14.0, @slack/types@npm:^2.18.0": version: 2.19.0 resolution: "@slack/types@npm:2.19.0" checksum: 10/d5fbc227147b32a964072cb46e2c328b896507a374194532ddf594e18e4ccc2afe9f31f2b92d14ace43ef856b22979523e565616be2c87a2dc6c575d4f919fd0 @@ -18454,7 +18383,7 @@ __metadata: languageName: node linkType: hard -"@smithy/signature-v4@npm:^4.1.0": +"@smithy/signature-v4@npm:^4.1.0, @smithy/signature-v4@npm:^4.1.1": version: 4.2.4 resolution: "@smithy/signature-v4@npm:4.2.4" dependencies: @@ -18470,22 +18399,6 @@ __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" @@ -18781,7 +18694,7 @@ __metadata: languageName: node linkType: hard -"@smithy/util-middleware@npm:^3.0.11": +"@smithy/util-middleware@npm:^3.0.11, @smithy/util-middleware@npm:^3.0.4": version: 3.0.11 resolution: "@smithy/util-middleware@npm:3.0.11" dependencies: @@ -18791,16 +18704,6 @@ __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" @@ -21106,14 +21009,14 @@ __metadata: linkType: hard "@types/express-serve-static-core@npm:*": - version: 4.19.6 - resolution: "@types/express-serve-static-core@npm:4.19.6" + version: 5.1.1 + resolution: "@types/express-serve-static-core@npm:5.1.1" dependencies: "@types/node": "npm:*" "@types/qs": "npm:*" "@types/range-parser": "npm:*" "@types/send": "npm:*" - checksum: 10/a2e00b6c5993f0dd63ada2239be81076fe0220314b9e9fde586e8946c9c09ce60f9a2dd0d74410ee2b5fd10af8c3e755a32bb3abf134533e2158142488995455 + checksum: 10/7f3d8cf7e68764c9f3e8f6a12825b69ccf5287347fc1c20b29803d4f08a4abc1153ae11d7258852c61aad50f62ef72d4c1b9c97092b0a90462c3dddec2f6026c languageName: node linkType: hard @@ -21138,19 +21041,7 @@ __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.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": +"@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": version: 4.17.25 resolution: "@types/express@npm:4.17.25" dependencies: @@ -21512,7 +21403,14 @@ __metadata: languageName: node linkType: hard -"@types/luxon@npm:^3.0.0, @types/luxon@npm:~3.4.0": +"@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": version: 3.4.2 resolution: "@types/luxon@npm:3.4.2" checksum: 10/fd89566e3026559f2bc4ddcc1e70a2c16161905ed50be9473ec0cfbbbe919165041408c4f6e06c4bcf095445535052e2c099087c76b1b38e368127e618fc968d @@ -21637,7 +21535,7 @@ __metadata: languageName: node linkType: hard -"@types/node-fetch@npm:^2.6.13": +"@types/node-fetch@npm:^2.6.13, @types/node-fetch@npm:^2.6.4": version: 2.6.13 resolution: "@types/node-fetch@npm:2.6.13" dependencies: @@ -21647,16 +21545,6 @@ __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" @@ -22175,17 +22063,6 @@ __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" @@ -22264,13 +22141,6 @@ __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" @@ -22506,7 +22376,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.33": +"@types/yargs@npm:^17.0.33, @types/yargs@npm:^17.0.8": version: 17.0.35 resolution: "@types/yargs@npm:17.0.35" dependencies: @@ -22515,15 +22385,6 @@ __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" @@ -22591,19 +22452,6 @@ __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" @@ -22660,15 +22508,6 @@ __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" @@ -22710,13 +22549,6 @@ __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" @@ -22769,7 +22601,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.54.0": +"@typescript-eslint/typescript-estree@npm:8.54.0, @typescript-eslint/typescript-estree@npm:^8.23.0": version: 8.54.0 resolution: "@typescript-eslint/typescript-estree@npm:8.54.0" dependencies: @@ -22788,26 +22620,6 @@ __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" @@ -22862,16 +22674,6 @@ __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" @@ -25497,18 +25299,7 @@ __metadata: languageName: node linkType: hard -"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": +"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": version: 1.13.4 resolution: "axios@npm:1.13.4" dependencies: @@ -28719,15 +28510,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.4.0": - version: 4.4.0 - resolution: "debug@npm:4.4.0" +"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" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad languageName: node linkType: hard @@ -28752,18 +28543,6 @@ __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" @@ -30907,20 +30686,13 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0": +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": 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" @@ -31229,7 +31001,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:30.2.0, expect@npm:^30.0.0": +"expect@npm:30.2.0, expect@npm:>28.1.3, expect@npm:^30.0.0": version: 30.2.0 resolution: "expect@npm:30.2.0" dependencies: @@ -31243,19 +31015,6 @@ __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" @@ -32284,7 +32043,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:11.3.3, fs-extra@npm:~11.3.0": +"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": version: 11.3.3 resolution: "fs-extra@npm:11.3.3" dependencies: @@ -32318,17 +32077,6 @@ __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" @@ -32606,25 +32354,7 @@ __metadata: languageName: node linkType: hard -"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": +"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": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -35831,7 +35561,7 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.2.0, jest-diff@npm:^29.7.0": +"jest-diff@npm:^29.2.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" dependencies: @@ -35931,18 +35661,6 @@ __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" @@ -35960,23 +35678,6 @@ __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" @@ -38176,7 +37877,14 @@ __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, 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": + version: 3.7.2 + resolution: "luxon@npm:3.7.2" + checksum: 10/b24cd205ed306ce7415991687897dcc4027921ae413c9116590bc33a95f93b86ce52cf74ba72b4f5c5ab1c10090517f54ac8edfb127c049e0bf55b90dc2260be + languageName: node + linkType: hard + +"luxon@npm:~3.5.0": version: 3.5.0 resolution: "luxon@npm:3.5.0" checksum: 10/48f86e6c1c96815139f8559456a3354a276ba79bcef0ae0d4f2172f7652f3ba2be2237b0e103b8ea0b79b47715354ac9fac04eb1db3485dcc72d5110491dd47f @@ -39174,7 +38882,7 @@ __metadata: languageName: node linkType: hard -"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": +"micromatch@npm:^4.0.2, 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: @@ -42225,20 +41933,13 @@ __metadata: languageName: node linkType: hard -"pg-connection-string@npm:^2.10.1": +"pg-connection-string@npm:^2.10.1, pg-connection-string@npm:^2.3.0, pg-connection-string@npm:^2.5.0": 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" @@ -42262,14 +41963,7 @@ __metadata: languageName: node linkType: hard -"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": +"pg-protocol@npm:*, pg-protocol@npm:^1.11.0": version: 1.11.0 resolution: "pg-protocol@npm:1.11.0" checksum: 10/a70b1b4a3fc5b1be80dfdd65c829a149b8bd9df7488f9c47e0b51c9413aec5eb6da0a9ae9812891d74cd9f2ee90c0e391984a41b64603e7375fcbb9e07070b08 @@ -46612,20 +46306,13 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:1.8.3": +"shell-quote@npm:1.8.3, shell-quote@npm:^1.6.1, shell-quote@npm:^1.7.3, shell-quote@npm:^1.8.1": 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" @@ -47290,7 +46977,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -48229,19 +47916,7 @@ __metadata: languageName: node linkType: hard -"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": +"tar-fs@npm:^2.0.0, tar-fs@npm:^2.1.4": version: 2.1.4 resolution: "tar-fs@npm:2.1.4" dependencies: @@ -49667,20 +49342,13 @@ __metadata: languageName: node linkType: hard -"undici@npm:^7.16.0": +"undici@npm:^7.16.0, undici@npm:^7.2.3": 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" @@ -51219,9 +50887,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.8.0": - version: 8.18.3 - resolution: "ws@npm:8.18.3" +"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" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -51230,7 +50898,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 + checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b languageName: node linkType: hard @@ -51264,21 +50932,6 @@ __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"