app-backend: add static asset store namespacing
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<StaticAssetRow>(
|
||||
'static_assets_cache',
|
||||
).whereIn(
|
||||
'path',
|
||||
assets.map(a => a.path),
|
||||
);
|
||||
const existingRows = await this.#db<StaticAssetRow>('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<StaticAsset | undefined> {
|
||||
const [row] = await this.#db<StaticAssetRow>('static_assets_cache').where({
|
||||
path,
|
||||
namespace: this.#namespace,
|
||||
});
|
||||
if (!row) {
|
||||
return undefined;
|
||||
@@ -151,6 +164,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
|
||||
]);
|
||||
}
|
||||
await this.#db<StaticAssetRow>('static_assets_cache')
|
||||
.where('namespace', this.#namespace)
|
||||
.where('last_modified_at', '<=', lastModifiedInterval)
|
||||
.delete();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user