Add app db support + app url migration

This commit is contained in:
Rory Powell 2022-01-27 10:40:31 +00:00
parent e5d04d2830
commit 767354ad60
17 changed files with 213 additions and 85 deletions

View File

@ -2,7 +2,12 @@ const { DEFAULT_TENANT_ID } = require("../constants")
const { DocumentTypes } = require("../db/constants") const { DocumentTypes } = require("../db/constants")
const { getAllApps } = require("../db/utils") const { getAllApps } = require("../db/utils")
const environment = require("../environment") const environment = require("../environment")
const { doInTenant, getTenantIds, getGlobalDBName } = require("../tenancy") const {
doInTenant,
getTenantIds,
getGlobalDBName,
getTenantId,
} = require("../tenancy")
exports.MIGRATION_TYPES = { exports.MIGRATION_TYPES = {
GLOBAL: "global", // run once, recorded in global db, global db is provided as an argument GLOBAL: "global", // run once, recorded in global db, global db is provided as an argument
@ -20,16 +25,18 @@ exports.getMigrationsDoc = async db => {
} }
} }
const runMigration = async (tenantId, CouchDB, migration, options = {}) => { const runMigration = async (CouchDB, migration, options = {}) => {
const tenantId = getTenantId()
const migrationType = migration.type const migrationType = migration.type
const migrationName = migration.name const migrationName = migration.name
// get the db to store the migration in // get the db to store the migration in
let dbNames let dbNames
if (migrationType === exports.MIGRATION_TYPES.GLOBAL) { if (migrationType === exports.MIGRATION_TYPES.GLOBAL) {
dbNames = [getGlobalDBName(tenantId)] dbNames = [getGlobalDBName()]
} else if (migrationType === exports.MIGRATION_TYPES.APP) { } else if (migrationType === exports.MIGRATION_TYPES.APP) {
dbNames = await getAllApps(CouchDB, { all: true }) const apps = await getAllApps(CouchDB, migration.opts)
dbNames = apps.map(app => app.appId)
} else { } else {
throw new Error( throw new Error(
`[Tenant: ${tenantId}] Unrecognised migration type [${migrationType}]` `[Tenant: ${tenantId}] Unrecognised migration type [${migrationType}]`
@ -50,7 +57,7 @@ const runMigration = async (tenantId, CouchDB, migration, options = {}) => {
options.force[migrationType].includes(migrationName) options.force[migrationType].includes(migrationName)
) { ) {
console.log( console.log(
`[Tenant: ${tenantId}] Forcing migration [${migrationName}]` `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Forcing`
) )
} else { } else {
// the migration has already been performed // the migration has already been performed
@ -59,18 +66,20 @@ const runMigration = async (tenantId, CouchDB, migration, options = {}) => {
} }
console.log( console.log(
`[Tenant: ${tenantId}] Performing migration: ${migrationName}` `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Running`
) )
// run the migration with tenant context // run the migration with tenant context
await doInTenant(tenantId, () => migration.fn(db)) await migration.fn(db)
console.log(`[Tenant: ${tenantId}] Migration complete: ${migrationName}`) console.log(
`[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Complete`
)
// mark as complete // mark as complete
doc[migrationName] = Date.now() doc[migrationName] = Date.now()
await db.put(doc) await db.put(doc)
} catch (err) { } catch (err) {
console.error( console.error(
`[Tenant: ${tenantId}] Error performing migration: ${migrationName} on db: ${db.name}: `, `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Error: `,
err err
) )
throw err throw err
@ -96,7 +105,9 @@ exports.runMigrations = async (CouchDB, migrations, options = {}) => {
// for all migrations // for all migrations
for (const migration of migrations) { for (const migration of migrations) {
// run the migration // run the migration
await runMigration(tenantId, CouchDB, migration, options) await doInTenant(tenantId, () =>
runMigration(CouchDB, migration, options)
)
} }
} }
console.log("Migrations complete") console.log("Migrations complete")

View File

@ -3,7 +3,7 @@
exports[`migrations should match snapshot 1`] = ` exports[`migrations should match snapshot 1`] = `
Object { Object {
"_id": "migrations", "_id": "migrations",
"_rev": "1-af6c272fe081efafecd2ea49a8fcbb40", "_rev": "1-6277abc4e3db950221768e5a2618a059",
"user_email_view_casing": 1487076708000, "test": 1487076708000,
} }
`; `;

View File

@ -1,7 +1,7 @@
require("../../tests/utilities/dbConfig") require("../../tests/utilities/dbConfig")
const { migrateIfRequired, MIGRATION_DBS, MIGRATIONS, getMigrationsDoc } = require("../index") const { runMigrations, getMigrationsDoc } = require("../index")
const database = require("../../db") const CouchDB = require("../../db").getCouch()
const { const {
StaticDatabases, StaticDatabases,
} = require("../../db/utils") } = require("../../db/utils")
@ -13,8 +13,14 @@ describe("migrations", () => {
const migrationFunction = jest.fn() const migrationFunction = jest.fn()
const MIGRATIONS = [{
type: "global",
name: "test",
fn: migrationFunction
}]
beforeEach(() => { beforeEach(() => {
db = database.getDB(StaticDatabases.GLOBAL.name) db = new CouchDB(StaticDatabases.GLOBAL.name)
}) })
afterEach(async () => { afterEach(async () => {
@ -22,39 +28,29 @@ describe("migrations", () => {
await db.destroy() await db.destroy()
}) })
const validMigration = () => { const migrate = () => {
return migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction) return runMigrations(CouchDB, MIGRATIONS)
} }
it("should run a new migration", async () => { it("should run a new migration", async () => {
await validMigration() await migrate()
expect(migrationFunction).toHaveBeenCalled() expect(migrationFunction).toHaveBeenCalled()
const doc = await getMigrationsDoc(db)
expect(doc.test).toBeDefined()
}) })
it("should match snapshot", async () => { it("should match snapshot", async () => {
await validMigration() await migrate()
const doc = await getMigrationsDoc(db) const doc = await getMigrationsDoc(db)
expect(doc).toMatchSnapshot() expect(doc).toMatchSnapshot()
}) })
it("should skip a previously run migration", async () => { it("should skip a previously run migration", async () => {
await validMigration() await migrate()
await validMigration() const previousMigrationTime = await getMigrationsDoc(db).test
await migrate()
const currentMigrationTime = await getMigrationsDoc(db).test
expect(migrationFunction).toHaveBeenCalledTimes(1) expect(migrationFunction).toHaveBeenCalledTimes(1)
expect(currentMigrationTime).toBe(previousMigrationTime)
}) })
it("should reject an unknown migration name", async () => {
expect(async () => {
await migrateIfRequired(MIGRATION_DBS.GLOBAL_DB, "bogus_name", migrationFunction)
}).rejects.toThrow()
expect(migrationFunction).not.toHaveBeenCalled()
})
it("should reject an unknown database name", async () => {
expect(async () => {
await migrateIfRequired("bogus_db", MIGRATIONS.USER_EMAIL_VIEW_CASING, migrationFunction)
}).rejects.toThrow()
expect(migrationFunction).not.toHaveBeenCalled()
})
}) })

View File

@ -75,7 +75,7 @@ function getUserRoleId(ctx) {
: ctx.user.role._id : ctx.user.role._id
} }
async function getAppUrl(ctx) { exports.getAppUrl = ctx => {
// construct the url // construct the url
let url let url
if (ctx.request.body.url) { if (ctx.request.body.url) {
@ -218,7 +218,7 @@ exports.create = async ctx => {
const apps = await getAllApps(CouchDB, { dev: true }) const apps = await getAllApps(CouchDB, { dev: true })
const name = ctx.request.body.name const name = ctx.request.body.name
checkAppName(ctx, apps, name) checkAppName(ctx, apps, name)
const url = await getAppUrl(ctx) const url = exports.getAppUrl(ctx)
checkAppUrl(ctx, apps, url) checkAppUrl(ctx, apps, url)
const { useTemplate, templateKey, templateString } = ctx.request.body const { useTemplate, templateKey, templateString } = ctx.request.body
@ -281,7 +281,7 @@ exports.update = async ctx => {
// validation // validation
const name = ctx.request.body.name const name = ctx.request.body.name
checkAppName(ctx, apps, name, ctx.params.appId) checkAppName(ctx, apps, name, ctx.params.appId)
const url = await getAppUrl(ctx) const url = exports.getAppUrl(ctx)
checkAppUrl(ctx, apps, url, ctx.params.appId) checkAppUrl(ctx, apps, url, ctx.params.appId)
const appPackageUpdates = { name, url } const appPackageUpdates = { name, url }

View File

@ -1,4 +1,4 @@
const { migrate } = require("../../migrations") const { migrate, MIGRATIONS } = require("../../migrations")
exports.migrate = async ctx => { exports.migrate = async ctx => {
const options = ctx.request.body const options = ctx.request.body
@ -6,3 +6,8 @@ exports.migrate = async ctx => {
migrate(options) migrate(options)
ctx.status = 200 ctx.status = 200
} }
exports.fetchDefinitions = async ctx => {
ctx.body = MIGRATIONS
ctx.status = 200
}

View File

@ -3,6 +3,12 @@ const migrationsController = require("../controllers/migrations")
const router = Router() const router = Router()
const { internalApi } = require("@budibase/backend-core/auth") const { internalApi } = require("@budibase/backend-core/auth")
router.post("/api/migrations/run", internalApi, migrationsController.migrate) router
.post("/api/migrations/run", internalApi, migrationsController.migrate)
.get(
"/api/migrations/definitions",
internalApi,
migrationsController.fetchDefinitions
)
module.exports = router module.exports = router

View File

@ -43,8 +43,8 @@ const coreFields = {
enum: Object.values(BodyTypes), enum: Object.values(BodyTypes),
}, },
pagination: { pagination: {
type: DatasourceFieldTypes.OBJECT type: DatasourceFieldTypes.OBJECT,
} },
} }
module RestModule { module RestModule {
@ -178,12 +178,17 @@ module RestModule {
headers, headers,
}, },
pagination: { pagination: {
cursor: nextCursor cursor: nextCursor,
} },
} }
} }
getUrl(path: string, queryString: string, pagination: PaginationConfig | null, paginationValues: PaginationValues | null): string { getUrl(
path: string,
queryString: string,
pagination: PaginationConfig | null,
paginationValues: PaginationValues | null
): string {
// Add pagination params to query string if required // Add pagination params to query string if required
if (pagination?.location === "query" && paginationValues) { if (pagination?.location === "query" && paginationValues) {
const { pageParam, sizeParam } = pagination const { pageParam, sizeParam } = pagination
@ -217,14 +222,22 @@ module RestModule {
return complete return complete
} }
addBody(bodyType: string, body: string | any, input: any, pagination: PaginationConfig | null, paginationValues: PaginationValues | null) { addBody(
bodyType: string,
body: string | any,
input: any,
pagination: PaginationConfig | null,
paginationValues: PaginationValues | null
) {
if (!input.headers) { if (!input.headers) {
input.headers = {} input.headers = {}
} }
if (bodyType === BodyTypes.NONE) { if (bodyType === BodyTypes.NONE) {
return input return input
} }
let error, object: any = {}, string = "" let error,
object: any = {},
string = ""
try { try {
if (body) { if (body) {
string = typeof body !== "string" ? JSON.stringify(body) : body string = typeof body !== "string" ? JSON.stringify(body) : body
@ -333,7 +346,7 @@ module RestModule {
requestBody, requestBody,
authConfigId, authConfigId,
pagination, pagination,
paginationValues paginationValues,
} = query } = query
const authHeaders = this.getAuthHeaders(authConfigId) const authHeaders = this.getAuthHeaders(authConfigId)
@ -352,7 +365,13 @@ module RestModule {
} }
let input: any = { method, headers: this.headers } let input: any = { method, headers: this.headers }
input = this.addBody(bodyType, requestBody, input, pagination, paginationValues) input = this.addBody(
bodyType,
requestBody,
input,
pagination,
paginationValues
)
this.startTimeMs = performance.now() this.startTimeMs = performance.now()
const url = this.getUrl(path, queryString, pagination, paginationValues) const url = this.getUrl(path, queryString, pagination, paginationValues)

View File

@ -38,7 +38,7 @@ module S3Module {
signatureVersion: { signatureVersion: {
type: "string", type: "string",
required: false, required: false,
default: "v4" default: "v4",
}, },
}, },
query: { query: {

View File

@ -0,0 +1,25 @@
const { DocumentTypes } = require("@budibase/backend-core/db")
import { getAppUrl } from "../../api/controllers/application"
/**
* Date:
* January 2022
*
* Description:
* Add the url to the app metadata if it doesn't exist
*/
export const run = async (appDb: any) => {
const metadata = await appDb.get(DocumentTypes.APP_METADATA)
if (!metadata.url) {
const context = {
request: {
body: {
name: metadata.name,
},
},
}
metadata.url = getAppUrl(context)
console.log(`Adding url to app: ${metadata.url}`)
}
await appDb.put(metadata)
}

View File

@ -0,0 +1,29 @@
const { DocumentTypes } = require("@budibase/backend-core/db")
const env = require("../../../environment")
const TestConfig = require("../../../tests/utilities/TestConfiguration")
const migration = require("../appUrls")
describe("run", () => {
let config = new TestConfig(false)
const CouchDB = config.getCouch()
beforeEach(async () => {
await config.init()
})
afterAll(config.end)
it("runs successfully", async () => {
const app = await config.createApp("testApp")
const appDb = new CouchDB(app.appId)
let metadata = await appDb.get(DocumentTypes.APP_METADATA)
delete metadata.url
await appDb.put(metadata)
await migration.run(appDb)
metadata = await appDb.get(DocumentTypes.APP_METADATA)
expect(metadata.url).toEqual("/testapp")
})
})

View File

@ -0,0 +1,27 @@
const env = require("../../../environment")
const TestConfig = require("../../../tests/utilities/TestConfiguration")
const syncApps = jest.fn()
const syncRows = jest.fn()
jest.mock("../usageQuotas/syncApps", () => ({ run: syncApps }) )
jest.mock("../usageQuotas/syncRows", () => ({ run: syncRows }) )
const migration = require("../quotas1")
describe("run", () => {
let config = new TestConfig(false)
beforeEach(async () => {
await config.init()
env._set("USE_QUOTAS", 1)
})
afterAll(config.end)
it("runs ", async () => {
await migration.run()
expect(syncApps).toHaveBeenCalledTimes(1)
expect(syncRows).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,25 @@
const TestConfig = require("../../../tests/utilities/TestConfiguration")
const { getGlobalDB } = require("@budibase/backend-core/tenancy")
// mock email view creation
const coreDb = require("@budibase/backend-core/db")
const createUserEmailView = jest.fn()
coreDb.createUserEmailView = createUserEmailView
const migration = require("../userEmailViewCasing")
describe("run", () => {
let config = new TestConfig(false)
const globalDb = getGlobalDB()
beforeEach(async () => {
await config.init()
})
afterAll(config.end)
it("runs successfully", async () => {
await migration.run(globalDb)
expect(createUserEmailView).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,27 +0,0 @@
const env = require("../../../../environment")
const TestConfig = require("../../../../tests/utilities/TestConfiguration")
const syncApps = jest.fn()
const syncRows = jest.fn()
jest.mock("../../usageQuotas/syncApps", () => ({ run: syncApps }) )
jest.mock("../../usageQuotas/syncRows", () => ({ run: syncRows }) )
const migrations = require("..")
describe("run", () => {
let config = new TestConfig(false)
beforeEach(async () => {
await config.init()
env._set("USE_QUOTAS", 1)
})
afterAll(config.end)
it("runs the required migrations", async () => {
await migrations.run()
expect(syncApps).toHaveBeenCalledTimes(1)
expect(syncRows).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,8 +1,8 @@
const { getGlobalDB } = require("@budibase/backend-core/tenancy") const { getGlobalDB } = require("@budibase/backend-core/tenancy")
const TestConfig = require("../../../tests/utilities/TestConfiguration") const TestConfig = require("../../../../tests/utilities/TestConfiguration")
const { getUsageQuotaDoc, update, Properties } = require("../../../utilities/usageQuota") const { getUsageQuotaDoc, update, Properties } = require("../../../../utilities/usageQuota")
const syncApps = require("../syncApps") const syncApps = require("../syncApps")
const env = require("../../../environment") const env = require("../../../../environment")
describe("syncApps", () => { describe("syncApps", () => {
let config = new TestConfig(false) let config = new TestConfig(false)

View File

@ -1,8 +1,8 @@
const { getGlobalDB } = require("@budibase/backend-core/tenancy") const { getGlobalDB } = require("@budibase/backend-core/tenancy")
const TestConfig = require("../../../tests/utilities/TestConfiguration") const TestConfig = require("../../../../tests/utilities/TestConfiguration")
const { getUsageQuotaDoc, update, Properties } = require("../../../utilities/usageQuota") const { getUsageQuotaDoc, update, Properties } = require("../../../../utilities/usageQuota")
const syncRows = require("../syncRows") const syncRows = require("../syncRows")
const env = require("../../../environment") const env = require("../../../../environment")
describe("syncRows", () => { describe("syncRows", () => {
let config = new TestConfig(false) let config = new TestConfig(false)

View File

@ -7,10 +7,12 @@ const {
// migration functions // migration functions
import * as userEmailViewCasing from "./functions/userEmailViewCasing" import * as userEmailViewCasing from "./functions/userEmailViewCasing"
import * as quota1 from "./functions/quotas1" import * as quota1 from "./functions/quotas1"
import * as appUrls from "./functions/appUrls"
export interface Migration { export interface Migration {
type: string type: string
name: string name: string
opts?: object
fn: Function fn: Function
} }
@ -30,7 +32,7 @@ export interface MigrationOptions {
} }
} }
const MIGRATIONS: Migration[] = [ export const MIGRATIONS: Migration[] = [
{ {
type: MIGRATION_TYPES.GLOBAL, type: MIGRATION_TYPES.GLOBAL,
name: "user_email_view_casing", name: "user_email_view_casing",
@ -41,6 +43,12 @@ const MIGRATIONS: Migration[] = [
name: "quotas_1", name: "quotas_1",
fn: quota1.run, fn: quota1.run,
}, },
{
type: MIGRATION_TYPES.APP,
name: "app_urls",
opts: { all: true },
fn: appUrls.run,
},
] ]
export const migrate = async (options?: MigrationOptions) => { export const migrate = async (options?: MigrationOptions) => {

View File

@ -49,6 +49,10 @@ class TestConfiguration {
return this.appId return this.appId
} }
getCouch() {
return CouchDB
}
async _req(config, params, controlFunc) { async _req(config, params, controlFunc) {
const request = {} const request = {}
// fake cookies, we don't need them // fake cookies, we don't need them