diff --git a/plugins/app-backend/migrations/20240113144027_assets-namespace.js b/plugins/app-backend/migrations/20240113144027_assets-namespace.js new file mode 100644 index 0000000000..5fd6ecc5aa --- /dev/null +++ b/plugins/app-backend/migrations/20240113144027_assets-namespace.js @@ -0,0 +1,38 @@ +/* + * Copyright 2021 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. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('static_assets_cache', table => { + // The namespace is used to allow operations on asset groups, e.g. delete all assets in a namespace + table.text('namespace').comment('The namespace of the file'); + table.index('namespace', 'static_asset_cache_namespace_idx'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('static_assets_cache', table => { + table.dropIndex([], 'static_asset_cache_namespace_idx'); + table.dropColumn('namespace'); + }); +}; diff --git a/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts b/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts index e18d136b9d..d975751676 100644 --- a/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts +++ b/plugins/app-backend/src/lib/assets/StaticAssetsStore.test.ts @@ -177,4 +177,44 @@ describe('StaticAssetsStore', () => { await expect(store.getAsset('old')).resolves.toBeUndefined(); }, ); + + it.each(databases.eachSupportedId())( + 'should isolate assets in namespace, %p', + async databaseId => { + const knex = await databases.init(databaseId); + const database = createDatabaseManager(knex); + const store = await StaticAssetsStore.create({ + logger, + database, + }); + const otherStore = store.withNamespace('other'); + + await store.storeAssets([ + { + path: 'foo', + content: async () => Buffer.alloc(0), + }, + ]); + await otherStore.storeAssets([ + { + path: 'bar', + content: async () => Buffer.alloc(0), + }, + ]); + + await expect(store.getAsset('foo')).resolves.toBeDefined(); + await expect(store.getAsset('bar')).resolves.not.toBeDefined(); + await expect(otherStore.getAsset('foo')).resolves.not.toBeDefined(); + await expect(otherStore.getAsset('bar')).resolves.toBeDefined(); + + await store.trimAssets({ maxAgeSeconds: 0 }); + + await expect(store.getAsset('foo')).resolves.not.toBeDefined(); + await expect(otherStore.getAsset('bar')).resolves.toBeDefined(); + + await otherStore.trimAssets({ maxAgeSeconds: 0 }); + + await expect(otherStore.getAsset('bar')).resolves.not.toBeDefined(); + }, + ); }); diff --git a/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts b/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts index 9f6ada717d..43bd38030f 100644 --- a/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts +++ b/plugins/app-backend/src/lib/assets/StaticAssetsStore.ts @@ -32,6 +32,7 @@ const migrationsDir = resolvePackagePath( interface StaticAssetRow { path: string; content: Buffer; + namespace: string | null; last_modified_at: Date; } @@ -49,6 +50,7 @@ export interface StaticAssetsStoreOptions { export class StaticAssetsStore implements StaticAssetProvider { #db: Knex; #logger: Logger; + #namespace: string | null; static async create(options: StaticAssetsStoreOptions) { const { database } = options; @@ -63,9 +65,17 @@ export class StaticAssetsStore implements StaticAssetProvider { return new StaticAssetsStore(client, options.logger); } - private constructor(client: Knex, logger: Logger) { + private constructor(client: Knex, logger: Logger, namespace?: string) { this.#db = client; this.#logger = logger; + this.#namespace = namespace ?? null; + } + + /** + * Creates a new store with the provided namespace, using the same underlying storage. + */ + withNamespace(namespace: string): StaticAssetsStore { + return new StaticAssetsStore(this.#db, this.#logger, namespace); } /** @@ -75,12 +85,12 @@ export class StaticAssetsStore implements StaticAssetProvider { * updated, but the contents will not. */ async storeAssets(assets: StaticAssetInput[]) { - const existingRows = await this.#db( - 'static_assets_cache', - ).whereIn( - 'path', - assets.map(a => a.path), - ); + const existingRows = await this.#db('static_assets_cache') + .where('namespace', this.#namespace) + .whereIn( + 'path', + assets.map(a => a.path), + ); const existingAssetPaths = new Set(existingRows.map(r => r.path)); const [modified, added] = partition(assets, asset => @@ -95,6 +105,7 @@ export class StaticAssetsStore implements StaticAssetProvider { .update({ last_modified_at: this.#db.fn.now(), }) + .where('namespace', this.#namespace) .whereIn( 'path', modified.map(a => a.path), @@ -107,6 +118,7 @@ export class StaticAssetsStore implements StaticAssetProvider { .insert({ path: asset.path, content: await asset.content(), + namespace: this.#namespace, }) .onConflict('path') .ignore(); @@ -119,6 +131,7 @@ export class StaticAssetsStore implements StaticAssetProvider { async getAsset(path: string): Promise { const [row] = await this.#db('static_assets_cache').where({ path, + namespace: this.#namespace, }); if (!row) { return undefined; @@ -151,6 +164,7 @@ export class StaticAssetsStore implements StaticAssetProvider { ]); } await this.#db('static_assets_cache') + .where('namespace', this.#namespace) .where('last_modified_at', '<=', lastModifiedInterval) .delete(); }