Browse Source

Merge pull request #6528 from nocodb/nc-feat/more-jobs-re

feat: more jobs and long polling for jobs
pull/6533/head
mertmit 1 year ago committed by GitHub
parent
commit
60a6683543
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 52
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  2. 43
      packages/nc-gui/components/dashboard/TreeView/index.vue
  3. 35
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  4. 40
      packages/nc-gui/components/dashboard/settings/Metadata.vue
  5. 92
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  6. 42
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  7. 60
      packages/nc-gui/components/dlg/AirtableImport.vue
  8. 9
      packages/nc-gui/components/general/Modal.vue
  9. 35
      packages/nc-gui/components/workspace/ProjectList.vue
  10. 26
      packages/nc-gui/nuxt-shim.d.ts
  11. 102
      packages/nc-gui/plugins/jobs.ts
  12. 92
      packages/nc-gui/plugins/poller.ts
  13. 2
      packages/nc-gui/utils/iconUtils.ts
  14. 47
      packages/nocodb/src/Noco.ts
  15. 40
      packages/nocodb/src/controllers/bases.controller.ts
  16. 32
      packages/nocodb/src/controllers/meta-diffs.controller.ts
  17. 3
      packages/nocodb/src/interface/Jobs.ts
  18. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  19. 16
      packages/nocodb/src/meta/migrations/v2/nc_036_base_deleted.ts
  20. 108
      packages/nocodb/src/models/Base.ts
  21. 18
      packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts
  22. 2
      packages/nocodb/src/modules/jobs/fallback/jobs.service.ts
  23. 301
      packages/nocodb/src/modules/jobs/jobs.controller.ts
  24. 110
      packages/nocodb/src/modules/jobs/jobs.gateway.ts
  25. 29
      packages/nocodb/src/modules/jobs/jobs.module.ts
  26. 46
      packages/nocodb/src/modules/jobs/jobs/base-create/base-create.controller.ts
  27. 28
      packages/nocodb/src/modules/jobs/jobs/base-create/base-create.processor.ts
  28. 36
      packages/nocodb/src/modules/jobs/jobs/base-delete/base-delete.controller.ts
  29. 23
      packages/nocodb/src/modules/jobs/jobs/base-delete/base-delete.processor.ts
  30. 70
      packages/nocodb/src/modules/jobs/jobs/meta-sync/meta-sync.controller.ts
  31. 30
      packages/nocodb/src/modules/jobs/jobs/meta-sync/meta-sync.processor.ts
  32. 91
      packages/nocodb/src/modules/jobs/redis/jobs-event.service.ts
  33. 31
      packages/nocodb/src/modules/jobs/redis/jobs.service.ts
  34. 2
      packages/nocodb/src/modules/metas/metas.module.ts
  35. 12
      packages/nocodb/src/run/cloud.ts
  36. 10
      packages/nocodb/src/run/docker.ts
  37. 10
      packages/nocodb/src/run/dockerEntry.ts
  38. 10
      packages/nocodb/src/run/dockerRunMysql.ts
  39. 10
      packages/nocodb/src/run/dockerRunPG.ts
  40. 10
      packages/nocodb/src/run/dockerRunPG_CyQuick.ts
  41. 12
      packages/nocodb/src/run/local.ts
  42. 48
      packages/nocodb/src/schema/swagger.json
  43. 25
      packages/nocodb/src/services/bases.service.ts
  44. 2
      packages/nocodb/src/services/columns.service.ts
  45. 287
      packages/nocodb/src/services/meta-diffs.service.ts
  46. 1
      packages/nocodb/src/utils/globals.ts

52
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -344,30 +344,44 @@ const duplicateProject = (project: ProjectType) => {
selectedProjectToDuplicate.value = project selectedProjectToDuplicate.value = project
isDuplicateDlgOpen.value = true isDuplicateDlgOpen.value = true
} }
const { $jobs } = useNuxtApp() const { $poller } = useNuxtApp()
const DlgProjectDuplicateOnOk = async (jobData: { id: string; project_id: string }) => { const DlgProjectDuplicateOnOk = async (jobData: { id: string; project_id: string }) => {
await loadProjects('workspace') await loadProjects('workspace')
$jobs.subscribe({ id: jobData.id }, undefined, async (status: string) => { $poller.subscribe(
if (status === JobStatus.COMPLETED) { { id: jobData.id },
await loadProjects('workspace') async (data: {
id: string
const project = projects.value.get(jobData.project_id) status?: string
data?: {
// open project after duplication error?: {
if (project) { message: string
await navigateToProject({ }
projectId: project.id, message?: string
type: project.type, result?: any
})
} }
} else if (status === JobStatus.FAILED) { }) => {
message.error('Failed to duplicate project') if (data.status !== 'close') {
await loadProjects('workspace') if (data.status === JobStatus.COMPLETED) {
} await loadProjects('workspace')
})
const project = projects.value.get(jobData.project_id)
// open project after duplication
if (project) {
await navigateToProject({
projectId: project.id,
type: project.type,
})
}
} else if (data.status === JobStatus.FAILED) {
message.error('Failed to duplicate project')
await loadProjects('workspace')
}
}
},
)
$e('a:project:duplicate') $e('a:project:duplicate')
} }
</script> </script>

43
packages/nc-gui/components/dashboard/TreeView/index.vue

@ -31,7 +31,7 @@ const { isUIAllowed } = useRoles()
const { addTab } = useTabs() const { addTab } = useTabs()
const { $e, $jobs } = useNuxtApp() const { $e, $poller } = useNuxtApp()
const router = useRouter() const router = useRouter()
@ -119,19 +119,34 @@ const duplicateTable = async (table: TableType) => {
'modelValue': isOpen, 'modelValue': isOpen,
'table': table, 'table': table,
'onOk': async (jobData: { id: string }) => { 'onOk': async (jobData: { id: string }) => {
$jobs.subscribe({ id: jobData.id }, undefined, async (status: string, data?: any) => { $poller.subscribe(
if (status === JobStatus.COMPLETED) { { id: jobData.id },
await loadTables() async (data: {
refreshCommandPalette() id: string
const newTable = tables.value.find((el) => el.id === data?.result?.id) status?: string
if (newTable) addTab({ title: newTable.title, id: newTable.id, type: newTable.type as TabType }) data?: {
error?: {
openTable(newTable!) message: string
} else if (status === JobStatus.FAILED) { }
message.error(t('msg.error.failedToDuplicateTable')) message?: string
await loadTables() result?: any
} }
}) }) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
await loadTables()
refreshCommandPalette()
const newTable = tables.value.find((el) => el.id === data?.data?.result?.id)
if (newTable) addTab({ title: newTable.title, id: newTable.id, type: newTable.type as TabType })
openTable(newTable!)
} else if (data.status === JobStatus.FAILED) {
message.error(t('msg.error.failedToDuplicateTable'))
await loadTables()
}
}
},
)
$e('a:table:duplicate') $e('a:table:duplicate')
}, },

35
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -31,8 +31,6 @@ const sources = ref<BaseType[]>([])
const activeBaseId = ref('') const activeBaseId = ref('')
const metadiffbases = ref<string[]>([])
const clientType = ref<ClientType>(ClientType.MYSQL) const clientType = ref<ClientType>(ClientType.MYSQL)
const isReloading = ref(false) const isReloading = ref(false)
@ -55,8 +53,6 @@ async function loadBases(changed?: boolean) {
if (baseList.list && baseList.list.length) { if (baseList.list && baseList.list.length) {
sources.value = baseList.list sources.value = baseList.list
} }
await loadMetaDiff()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
@ -65,21 +61,6 @@ async function loadBases(changed?: boolean) {
} }
} }
async function loadMetaDiff() {
try {
metadiffbases.value = []
const metadiff = await $api.project.metaDiffGet(project.value.id as string)
for (const model of metadiff) {
if (model.detectedChanges?.length > 0) {
metadiffbases.value.push(model.base_id)
}
}
} catch (e) {
console.error(e)
}
}
const baseAction = (baseId?: string, action?: string) => { const baseAction = (baseId?: string, action?: string) => {
if (!baseId) return if (!baseId) return
activeBaseId.value = baseId activeBaseId.value = baseId
@ -362,11 +343,7 @@ const isEditBaseModalOpen = computed({
@click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)" @click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)"
> >
<div class="flex items-center gap-2 text-gray-600"> <div class="flex items-center gap-2 text-gray-600">
<a-tooltip v-if="metadiffbases.includes(sources[0].id)"> <GeneralIcon icon="sync" class="group-hover:text-accent" />
<template #title>{{ $t('activity.outOfSync') }}</template>
<GeneralIcon icon="warning" class="group-hover:text-accent text-primary" />
</a-tooltip>
<GeneralIcon v-else icon="sync" class="group-hover:text-accent" />
{{ $t('tooltip.metaSync') }} {{ $t('tooltip.metaSync') }}
</div> </div>
</a-button> </a-button>
@ -472,11 +449,7 @@ const isEditBaseModalOpen = computed({
@click="baseAction(base.id, DataSourcesSubTab.Metadata)" @click="baseAction(base.id, DataSourcesSubTab.Metadata)"
> >
<div class="flex items-center gap-2 text-gray-600"> <div class="flex items-center gap-2 text-gray-600">
<a-tooltip v-if="metadiffbases.includes(base.id)"> <GeneralIcon icon="sync" class="group-hover:text-accent" />
<template #title>{{ $t('activity.outOfSync') }}</template>
<GeneralIcon icon="warning" class="group-hover:text-accent text-primary" />
</a-tooltip>
<GeneralIcon v-else icon="sync" class="group-hover:text-accent" />
{{ $t('tooltip.metaSync') }} {{ $t('tooltip.metaSync') }}
</div> </div>
</a-button> </a-button>
@ -505,7 +478,7 @@ const isEditBaseModalOpen = computed({
</Draggable> </Draggable>
</div> </div>
</div> </div>
<GeneralModal v-model:visible="isNewBaseModalOpen" size="medium"> <GeneralModal v-model:visible="isNewBaseModalOpen" closable :mask-closable="false" size="medium">
<div class="py-6 px-8"> <div class="py-6 px-8">
<LazyDashboardSettingsDataSourcesCreateBase <LazyDashboardSettingsDataSourcesCreateBase
:connection-type="clientType" :connection-type="clientType"
@ -529,7 +502,7 @@ const isEditBaseModalOpen = computed({
<LazyDashboardSettingsUIAcl :base-id="activeBaseId" /> <LazyDashboardSettingsUIAcl :base-id="activeBaseId" />
</div> </div>
</GeneralModal> </GeneralModal>
<GeneralModal v-model:visible="isEditBaseModalOpen" size="medium"> <GeneralModal v-model:visible="isEditBaseModalOpen" closable :mask-closable="false" size="medium">
<div class="p-6"> <div class="p-6">
<LazyDashboardSettingsDataSourcesEditBase <LazyDashboardSettingsDataSourcesEditBase
:base-id="activeBaseId" :base-id="activeBaseId"

40
packages/nc-gui/components/dashboard/settings/Metadata.vue

@ -41,21 +41,45 @@ async function loadMetaDiff() {
} }
} }
const { $poller } = useNuxtApp()
async function syncMetaDiff() { async function syncMetaDiff() {
try { try {
if (!project.value?.id || !isDifferent.value) return if (!project.value?.id || !isDifferent.value) return
isLoading.value = true isLoading.value = true
await $api.base.metaDiffSync(project.value?.id, props.baseId) const jobData = await $api.base.metaDiffSync(project.value?.id, props.baseId)
// Table metadata recreated successfully
message.info(t('msg.info.metaDataRecreated')) $poller.subscribe(
await loadTables() { id: jobData.id },
await loadMetaDiff() async (data: {
emit('baseSynced') id: string
status?: string
data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
// Table metadata recreated successfully
message.info(t('msg.info.metaDataRecreated'))
await loadTables()
await loadMetaDiff()
emit('baseSynced')
isLoading.value = false
} else if (status === JobStatus.FAILED) {
message.error('Failed to sync base metadata')
isLoading.value = false
}
}
},
)
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoading.value = false
} }
} }

92
packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue

@ -44,6 +44,8 @@ const useForm = Form.useForm
const testSuccess = ref(false) const testSuccess = ref(false)
const testingConnection = ref(false)
const form = ref<typeof Form>() const form = ref<typeof Form>()
const { api } = useApi() const { api } = useApi()
@ -52,6 +54,8 @@ const { $e } = useNuxtApp()
const { t } = useI18n() const { t } = useI18n()
const creatingBase = ref(false)
const formState = ref<ProjectCreateForm>({ const formState = ref<ProjectCreateForm>({
title: '', title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) }, dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
@ -225,26 +229,27 @@ function getConnectionConfig() {
const focusInvalidInput = () => { const focusInvalidInput = () => {
form.value?.$el.querySelector('.ant-form-item-explain-error')?.parentNode?.parentNode?.querySelector('input')?.focus() form.value?.$el.querySelector('.ant-form-item-explain-error')?.parentNode?.parentNode?.querySelector('input')?.focus()
} }
const isConnSuccess = ref(false)
const { $poller } = useNuxtApp()
const createBase = async () => { const createBase = async () => {
try { try {
await validate() await validate()
isConnSuccess.value = false
} catch (e) { } catch (e) {
focusInvalidInput() focusInvalidInput()
isConnSuccess.value = false
return return
} }
try { try {
if (!projectId.value) return if (!projectId.value) return
creatingBase.value = true
const connection = getConnectionConfig() const connection = getConnectionConfig()
const config = { ...formState.value.dataSource, connection } const config = { ...formState.value.dataSource, connection }
await api.base.create(projectId.value, { const jobData = await api.base.create(projectId.value, {
alias: formState.value.title, alias: formState.value.title,
type: formState.value.dataSource.client, type: formState.value.dataSource.client,
config, config,
@ -252,12 +257,38 @@ const createBase = async () => {
inflection_table: formState.value.inflection.inflectionTable, inflection_table: formState.value.inflection.inflectionTable,
}) })
$e('a:base:create:extdb') $poller.subscribe(
{ id: jobData.id },
await loadProject(projectId.value, true) async (data: {
await loadProjectTables(projectId.value, true) id: string
emit('baseCreated') status?: string
emit('close') data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
$e('a:base:create:extdb')
if (projectId.value) {
await loadProject(projectId.value, true)
await loadProjectTables(projectId.value, true)
}
emit('baseCreated')
emit('close')
creatingBase.value = false
} else if (status === JobStatus.FAILED) {
message.error('Failed to create base')
creatingBase.value = false
}
}
},
)
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -274,6 +305,8 @@ const testConnection = async () => {
$e('a:base:create:extdb:test-connection', []) $e('a:base:create:extdb:test-connection', [])
try { try {
testingConnection.value = true
if (formState.value.dataSource.client === ClientType.SQLITE) { if (formState.value.dataSource.client === ClientType.SQLITE) {
testSuccess.value = true testSuccess.value = true
} else { } else {
@ -290,7 +323,6 @@ const testConnection = async () => {
if (result.code === 0) { if (result.code === 0) {
testSuccess.value = true testSuccess.value = true
isConnSuccess.value = true
} else { } else {
testSuccess.value = false testSuccess.value = false
@ -302,6 +334,8 @@ const testConnection = async () => {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
testingConnection.value = false
} }
const handleImportURL = async () => { const handleImportURL = async () => {
@ -367,15 +401,6 @@ watch(
</script> </script>
<template> <template>
<GeneralModal v-model:visible="isConnSuccess" class="!w-[25rem]">
<div class="flex flex-col h-full p-8">
<div class="text-lg font-semibold self-start mb-4">{{ t('msg.info.dbConnected') }}</div>
<div class="flex gap-x-2 mt-5 ml-7 pt-2.5 justify-end">
<NcButton key="back" type="secondary" @click="isConnSuccess = false">{{ $t('general.cancel') }}</NcButton>
<NcButton key="submit" type="primary" @click="createBase"> {{ $t('activity.addBase') }}</NcButton>
</div>
</div>
</GeneralModal>
<div class="create-base bg-white relative flex flex-col justify-center gap-2 w-full"> <div class="create-base bg-white relative flex flex-col justify-center gap-2 w-full">
<h1 class="prose-2xl font-bold self-start mb-4 flex items-center gap-2"> <h1 class="prose-2xl font-bold self-start mb-4 flex items-center gap-2">
{{ $t('title.newBase') }} {{ $t('title.newBase') }}
@ -470,7 +495,7 @@ watch(
</a-form-item> </a-form-item>
<div class="flex items-right justify-end gap-2"> <div class="flex items-right justify-end gap-2">
<!-- Use Connection URL --> <!-- Use Connection URL -->
<NcButton size="small" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true"> <NcButton type="ghost" size="small" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true">
{{ $t('activity.useConnectionUrl') }} {{ $t('activity.useConnectionUrl') }}
</NcButton> </NcButton>
</div> </div>
@ -563,7 +588,7 @@ watch(
v-model:value="formState.inflection.inflectionTable" v-model:value="formState.inflection.inflectionTable"
dropdown-class-name="nc-dropdown-inflection-table-name" dropdown-class-name="nc-dropdown-inflection-table-name"
> >
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }} </a-select-option> <a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp">{{ tp }} </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -572,7 +597,7 @@ watch(
v-model:value="formState.inflection.inflectionColumn" v-model:value="formState.inflection.inflectionColumn"
dropdown-class-name="nc-dropdown-inflection-column-name" dropdown-class-name="nc-dropdown-inflection-column-name"
> >
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }} </a-select-option> <a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp">{{ tp }} </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -589,7 +614,14 @@ watch(
<a-form-item class="flex justify-end !mt-5"> <a-form-item class="flex justify-end !mt-5">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<NcButton type="primary" size="small" class="nc-extdb-btn-test-connection !rounded-md" @click="testConnection"> <NcButton
:type="testSuccess ? 'ghost' : 'primary'"
size="small"
class="nc-extdb-btn-test-connection !rounded-md"
:loading="testingConnection"
@click="testConnection"
>
<GeneralIcon v-if="testSuccess" icon="circleCheck" class="text-primary mr-2" />
{{ $t('activity.testDbConn') }} {{ $t('activity.testDbConn') }}
</NcButton> </NcButton>
@ -597,6 +629,7 @@ watch(
size="small" size="small"
type="primary" type="primary"
:disabled="!testSuccess" :disabled="!testSuccess"
:loading="creatingBase"
class="nc-extdb-btn-submit !rounded-md" class="nc-extdb-btn-submit !rounded-md"
@click="createBase" @click="createBase"
> >
@ -628,17 +661,6 @@ watch(
> >
<a-input v-model:value="importURL" /> <a-input v-model:value="importURL" />
</a-modal> </a-modal>
<!-- connection succesfull modal -->
<GeneralModal v-model:visible="isConnSuccess" class="!w-[25rem]">
<div class="flex flex-col h-full p-8">
<div class="text-lg font-semibold self-start mb-4">{{ t('msg.info.dbConnected') }}</div>
<div class="flex gap-x-2 mt-5 ml-7 pt-2.5 justify-end">
<NcButton key="back" type="secondary" @click="isConnSuccess = false">{{ $t('general.cancel') }}</NcButton>
<NcButton key="submit" type="primary" @click="createBase">{{ $t('activity.addBase') }}</NcButton>
</div>
</div>
</GeneralModal>
</div> </div>
</template> </template>

42
packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue

@ -43,6 +43,8 @@ const useForm = Form.useForm
const testSuccess = ref(false) const testSuccess = ref(false)
const testingConnection = ref(false)
const form = ref<typeof Form>() const form = ref<typeof Form>()
const { api } = useApi() const { api } = useApi()
@ -51,6 +53,8 @@ const { $e } = useNuxtApp()
const { t } = useI18n() const { t } = useI18n()
const editingBase = ref(false)
const formState = ref<ProjectCreateForm>({ const formState = ref<ProjectCreateForm>({
title: '', title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) }, dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
@ -234,8 +238,6 @@ const editBase = async () => {
} }
} }
const isConnSuccess = ref(false)
const testConnection = async () => { const testConnection = async () => {
try { try {
await validate() await validate()
@ -247,6 +249,8 @@ const testConnection = async () => {
$e('a:base:edit:extdb:test-connection', []) $e('a:base:edit:extdb:test-connection', [])
try { try {
testingConnection.value = true
if (formState.value.dataSource.client === ClientType.SQLITE) { if (formState.value.dataSource.client === ClientType.SQLITE) {
testSuccess.value = true testSuccess.value = true
} else { } else {
@ -263,7 +267,6 @@ const testConnection = async () => {
if (result.code === 0) { if (result.code === 0) {
testSuccess.value = true testSuccess.value = true
isConnSuccess.value = true
} else { } else {
testSuccess.value = false testSuccess.value = false
@ -275,6 +278,8 @@ const testConnection = async () => {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
testingConnection.value = false
} }
const handleImportURL = async () => { const handleImportURL = async () => {
@ -427,7 +432,7 @@ onMounted(async () => {
</a-form-item> </a-form-item>
<!-- Use Connection URL --> <!-- Use Connection URL -->
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<NcButton size="small" type="primary" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true"> <NcButton size="small" type="ghost" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true">
{{ $t('activity.useConnectionUrl') }} {{ $t('activity.useConnectionUrl') }}
</NcButton> </NcButton>
</div> </div>
@ -519,7 +524,7 @@ onMounted(async () => {
v-model:value="formState.inflection.inflectionTable" v-model:value="formState.inflection.inflectionTable"
dropdown-class-name="nc-dropdown-inflection-table-name" dropdown-class-name="nc-dropdown-inflection-table-name"
> >
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option> <a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp">{{ tp }}</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -528,7 +533,7 @@ onMounted(async () => {
v-model:value="formState.inflection.inflectionColumn" v-model:value="formState.inflection.inflectionColumn"
dropdown-class-name="nc-dropdown-inflection-column-name" dropdown-class-name="nc-dropdown-inflection-column-name"
> >
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option> <a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp">{{ tp }}</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -545,15 +550,23 @@ onMounted(async () => {
<a-form-item class="flex justify-end !mt-5"> <a-form-item class="flex justify-end !mt-5">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<NcButton type="secondary" size="small" class="nc-extdb-btn-test-connection !rounded-md" @click="testConnection"> <NcButton
:type="testSuccess ? 'ghost' : 'primary'"
size="small"
class="nc-extdb-btn-test-connection !rounded-md"
:loading="testingConnection"
@click="testConnection"
>
<GeneralIcon v-if="testSuccess" icon="circleCheck" class="text-primary mr-2" />
{{ $t('activity.testDbConn') }} {{ $t('activity.testDbConn') }}
</NcButton> </NcButton>
<NcButton <NcButton
class="nc-extdb-btn-submit !rounded-md"
size="small" size="small"
type="primary" type="primary"
:disabled="!testSuccess" :disabled="!testSuccess"
class="nc-extdb-btn-submit !rounded-md" :loading="editingBase"
@click="editBase" @click="editBase"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
@ -569,7 +582,7 @@ onMounted(async () => {
<a-modal <a-modal
v-model:visible="configEditDlg" v-model:visible="configEditDlg"
:title="$t('activity.editConnJson')" :title="$t('activity.editConnJson')"
width="600px" width="500px"
wrap-class-name="nc-modal-edit-connection-json" wrap-class-name="nc-modal-edit-connection-json"
@ok="handleOk" @ok="handleOk"
> >
@ -589,17 +602,6 @@ onMounted(async () => {
<a-input v-model:value="importURL" /> <a-input v-model:value="importURL" />
</a-modal> </a-modal>
</div> </div>
<!-- connection succesfull modal -->
<GeneralModal v-model:visible="isConnSuccess" class="!w-97">
<div class="flex flex-col h-full p-8">
<div class="text-lg font-semibold self-start mb-4">{{ t('msg.info.dbConnected') }}</div>
<div class="flex gap-x-2 mt-5 ml-7 pt-2.5 justify-end">
<NcButton key="back" type="secondary" @click="isConnSuccess = false">{{ $t('general.cancel') }}</NcButton>
<NcButton key="submit" type="primary" @click="editBase">{{ $t('activity.okEditBase') }}</NcButton>
</div>
</div>
</GeneralModal>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>

60
packages/nc-gui/components/dlg/AirtableImport.vue

@ -28,7 +28,7 @@ const { $api } = useNuxtApp()
const baseURL = $api.instance.defaults.baseURL const baseURL = $api.instance.defaults.baseURL
const { $state, $jobs } = useNuxtApp() const { $state, $poller } = useNuxtApp()
const projectStore = useProject() const projectStore = useProject()
@ -48,6 +48,10 @@ const logRef = ref<typeof AntCard>()
const enableAbort = ref(false) const enableAbort = ref(false)
const goBack = ref(false)
const listeningForUpdates = ref(false)
const syncSource = ref({ const syncSource = ref({
id: '', id: '',
type: 'Airtable', type: 'Airtable',
@ -81,10 +85,6 @@ const pushProgress = async (message: string, status: JobStatus | 'progress') =>
}) })
} }
const onSubscribe = () => {
step.value = 2
}
const onStatus = async (status: JobStatus, data?: any) => { const onStatus = async (status: JobStatus, data?: any) => {
if (status === JobStatus.COMPLETED) { if (status === JobStatus.COMPLETED) {
showGoToDashboardButton.value = true showGoToDashboardButton.value = true
@ -93,6 +93,7 @@ const onStatus = async (status: JobStatus, data?: any) => {
refreshCommandPalette() refreshCommandPalette()
// TODO: add tab of the first table // TODO: add tab of the first table
} else if (status === JobStatus.FAILED) { } else if (status === JobStatus.FAILED) {
goBack.value = true
pushProgress(data.error.message, status) pushProgress(data.error.message, status)
} }
} }
@ -146,6 +147,45 @@ async function createOrUpdate() {
} }
} }
async function listenForUpdates() {
if (listeningForUpdates.value) return
listeningForUpdates.value = true
const job = await $api.jobs.status({ syncId: syncSource.value.id })
if (!job) {
listeningForUpdates.value = false
return
}
$poller.subscribe(
{ id: job.id },
(data: {
id: string
status?: string
data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
step.value = 2
if (data.status) {
onStatus(data.status as JobStatus, data.data)
} else {
onLog(data.data as any)
}
} else {
listeningForUpdates.value = false
}
},
)
}
async function loadSyncSrc() { async function loadSyncSrc() {
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs/${baseId}`, { const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs/${baseId}`, {
baseURL, baseURL,
@ -160,7 +200,7 @@ async function loadSyncSrc() {
syncSource.value = migrateSync(srcs[0]) syncSource.value = migrateSync(srcs[0])
syncSource.value.details.syncSourceUrlOrId = syncSource.value.details.syncSourceUrlOrId =
srcs[0].details.appId && srcs[0].details.appId.length > 0 ? srcs[0].details.syncSourceUrlOrId : srcs[0].details.shareId srcs[0].details.appId && srcs[0].details.appId.length > 0 ? srcs[0].details.syncSourceUrlOrId : srcs[0].details.shareId
$jobs.subscribe({ syncId: syncSource.value.id }, onSubscribe, onStatus, onLog) listenForUpdates()
} else { } else {
syncSource.value = { syncSource.value = {
id: '', id: '',
@ -194,7 +234,7 @@ async function sync() {
method: 'POST', method: 'POST',
headers: { 'xc-auth': $state.token.value as string }, headers: { 'xc-auth': $state.token.value as string },
}) })
$jobs.subscribe({ syncId: syncSource.value.id }, onSubscribe, onStatus, onLog) listenForUpdates()
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -252,9 +292,8 @@ watch(
onMounted(async () => { onMounted(async () => {
if (syncSource.value.id) { if (syncSource.value.id) {
$jobs.subscribe({ syncId: syncSource.value.id }, onSubscribe, onStatus, onLog) listenForUpdates()
} }
await loadSyncSrc() await loadSyncSrc()
}) })
</script> </script>
@ -420,6 +459,9 @@ onMounted(async () => {
<a-button v-if="showGoToDashboardButton" class="mt-4" size="large" @click="dialogShow = false"> <a-button v-if="showGoToDashboardButton" class="mt-4" size="large" @click="dialogShow = false">
{{ $t('labels.goToDashboard') }} {{ $t('labels.goToDashboard') }}
</a-button> </a-button>
<a-button v-else-if="goBack" class="mt-4 uppercase" size="large" danger @click="step = 1">{{
$t('general.cancel')
}}</a-button>
<a-button v-else-if="enableAbort" class="mt-4 uppercase" size="large" danger @click="abort()">{{ <a-button v-else-if="enableAbort" class="mt-4 uppercase" size="large" danger @click="abort()">{{
$t('general.abort') $t('general.abort')
}}</a-button> }}</a-button>

9
packages/nc-gui/components/general/Modal.vue

@ -5,16 +5,20 @@ const props = withDefaults(
width?: string | number width?: string | number
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
destroyOnClose?: boolean destroyOnClose?: boolean
maskClosable?: boolean
closable?: boolean
}>(), }>(),
{ {
size: 'medium', size: 'medium',
destroyOnClose: true, destroyOnClose: true,
maskClosable: true,
closable: false,
}, },
) )
const emits = defineEmits(['update:visible']) const emits = defineEmits(['update:visible'])
const { width: propWidth, destroyOnClose } = props const { width: propWidth, destroyOnClose, closable, maskClosable } = props
const width = computed(() => { const width = computed(() => {
if (propWidth) { if (propWidth) {
@ -60,10 +64,11 @@ const visible = useVModel(props, 'visible', emits)
v-model:visible="visible" v-model:visible="visible"
:class="{ active: visible }" :class="{ active: visible }"
:width="width" :width="width"
:closable="false" :closable="closable"
wrap-class-name="nc-modal-wrapper" wrap-class-name="nc-modal-wrapper"
:footer="null" :footer="null"
:destroy-on-close="destroyOnClose" :destroy-on-close="destroyOnClose"
:mask-closable="maskClosable"
@keydown.esc="visible = false" @keydown.esc="visible = false"
> >
<div :class="`nc-modal max-h-[${height}]`"> <div :class="`nc-modal max-h-[${height}]`">

35
packages/nc-gui/components/workspace/ProjectList.vue

@ -18,7 +18,7 @@ const { navigateToProject } = useGlobal()
// const filteredProjects = computed(() => projects.value?.filter((p) => !p.deleted) || []) // const filteredProjects = computed(() => projects.value?.filter((p) => !p.deleted) || [])
const { $e, $jobs } = useNuxtApp() const { $e, $poller } = useNuxtApp()
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
@ -145,15 +145,30 @@ const selectedProjectToDuplicate = ref()
const DlgProjectDuplicateOnOk = async (jobData: { id: string }) => { const DlgProjectDuplicateOnOk = async (jobData: { id: string }) => {
await loadProjects('workspace') await loadProjects('workspace')
$jobs.subscribe({ id: jobData.id }, undefined, async (status: string) => { $poller.subscribe(
if (status === JobStatus.COMPLETED) { { id: jobData.id },
await loadProjects('workspace') async (data: {
refreshCommandPalette() id: string
} else if (status === JobStatus.FAILED) { status?: string
message.error('Failed to duplicate project') data?: {
await loadProjects('workspace') error?: {
} message: string
}) }
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
await loadProjects('workspace')
refreshCommandPalette()
} else if (data.status === JobStatus.FAILED) {
message.error('Failed to duplicate project')
await loadProjects('workspace')
}
}
},
)
$e('a:project:duplicate') $e('a:project:duplicate')
} }

26
packages/nc-gui/nuxt-shim.d.ts vendored

@ -1,6 +1,6 @@
import type { Api as BaseAPI } from 'nocodb-sdk' import type { Api as BaseAPI } from 'nocodb-sdk'
import type { UseGlobalReturn } from './composables/useGlobal/types' import type { UseGlobalReturn } from './composables/useGlobal/types'
import type { JobStatus, NocoI18n } from './lib' import type { NocoI18n } from './lib'
import type { TabType } from './composables' import type { TabType } from './composables'
declare module '#app/nuxt' { declare module '#app/nuxt' {
@ -13,18 +13,22 @@ declare module '#app/nuxt' {
/** {@link import('./plugins/tele') Telemetry} Emit telemetry event */ /** {@link import('./plugins/tele') Telemetry} Emit telemetry event */
$e: (event: string, data?: any) => void $e: (event: string, data?: any) => void
$state: UseGlobalReturn $state: UseGlobalReturn
$jobs: { $poller: {
subscribe( subscribe(
job: topic: { id: string },
| { cb: (data: {
id: string id: string
status?: string
data?: {
error?: {
message: string
} }
| any, message?: string
subscribedCb?: () => void, result?: any
statusCb?: ((status: JobStatus, error?: any) => void) | undefined, }
logCb?: ((data: { message: string }) => void) | undefined, }) => void,
): void _mid = 0,
getStatus(name: string, id: string): Promise<string> ): Promise<void>
} }
} }
} }

102
packages/nc-gui/plugins/jobs.ts

@ -1,102 +0,0 @@
import type { Socket } from 'socket.io-client'
import io from 'socket.io-client'
import { JobStatus, defineNuxtPlugin, useGlobal, watch } from '#imports'
export default defineNuxtPlugin(async (nuxtApp) => {
const { appInfo } = useGlobal()
let socket: Socket | null = null
let messageIndex = 0
const init = async (token: string) => {
try {
if (socket) socket.disconnect()
const url = new URL(appInfo.value.ncSiteUrl, window.location.href.split(/[?#]/)[0])
let socketPath = url.pathname
socketPath += socketPath.endsWith('/') ? 'socket.io' : '/socket.io'
socket = io(`${url.href}jobs`, {
extraHeaders: { 'xc-auth': token },
path: socketPath,
})
socket.on('connect_error', (e) => {
console.error(e)
socket?.disconnect()
})
} catch {}
}
if (nuxtApp.$state.signedIn.value) {
await init(nuxtApp.$state.token.value)
}
const send = (evt: string, data: any) => {
if (socket) {
const _id = messageIndex++
socket.emit(evt, { _id, data })
return _id
}
}
const jobs = {
subscribe(
job: { id: string } | any,
subscribedCb?: () => void,
statusCb?: (status: JobStatus, data?: any) => void,
logCb?: (data: { message: string }) => void,
) {
const logFn = (data: { id: string; data: { message: string } }) => {
if (data.id === job.id) {
if (logCb) logCb(data.data)
}
}
const statusFn = (data: any) => {
if (data.id === job.id) {
if (statusCb) statusCb(data.status, data.data)
if (data.status === JobStatus.COMPLETED || data.status === JobStatus.FAILED) {
socket?.off('status', statusFn)
socket?.off('log', logFn)
}
}
}
const _id = send('subscribe', job)
const subscribeFn = (data: { _id: number; id: string }) => {
if (data._id === _id) {
if (data.id !== job.id) {
job.id = data.id
}
if (subscribedCb) subscribedCb()
socket?.on('log', logFn)
socket?.on('status', statusFn)
socket?.off('subscribed', subscribeFn)
}
}
socket?.on('subscribed', subscribeFn)
},
getStatus(id: string): Promise<string> {
return new Promise((resolve) => {
if (socket) {
const _id = send('status', { id })
const tempFn = (data: any) => {
if (data._id === _id) {
resolve(data.status)
socket?.off('status', tempFn)
}
}
socket.on('status', tempFn)
}
})
},
}
watch((nuxtApp.$state as ReturnType<typeof useGlobal>).token, (newToken, oldToken) => {
if (newToken && newToken !== oldToken) init(newToken)
else if (!newToken) socket?.disconnect()
})
nuxtApp.provide('jobs', jobs)
})

92
packages/nc-gui/plugins/poller.ts

@ -0,0 +1,92 @@
import type { Api as BaseAPI } from 'nocodb-sdk'
import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin(async (nuxtApp) => {
const api: BaseAPI<any> = nuxtApp.$api as any
// unsubscribe all if signed out
let unsub = false
const subscribe = async (
topic: { id: string } | any,
cb: (data: {
id: string
status?: string
data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => void,
_mid = 0,
) => {
if (unsub) return
try {
const response:
| {
_mid: number
id: string
status: 'refresh' | 'update' | 'close'
data: any
}
| {
_mid: number
id: string
status: 'refresh' | 'update' | 'close'
data: any
}[] = await api.jobs.listen({ _mid, data: topic })
if (Array.isArray(response)) {
let lastMid = 0
for (const r of response) {
if (r.status === 'close') {
return cb(r)
} else {
if (r.status === 'update') {
cb(r.data)
}
lastMid = r._mid
}
}
await subscribe(topic, cb, lastMid)
} else {
if (response.status === 'close') {
return cb(response)
} else if (response.status === 'update') {
cb(response.data)
await subscribe(topic, cb, response._mid)
} else if (response.status === 'refresh') {
await subscribe(topic, cb, _mid)
}
}
} catch (e) {
setTimeout(() => {
subscribe(topic, cb, _mid)
}, 1000)
}
}
const init = () => {
unsub = false
}
if ((nuxtApp.$state as ReturnType<typeof useGlobal>).signedIn.value) {
await init()
}
watch((nuxtApp.$state as ReturnType<typeof useGlobal>).token, (newToken, oldToken) => {
if (newToken && newToken !== oldToken) init()
else if (!newToken) {
unsub = true
}
})
const poller = {
subscribe,
}
nuxtApp.provide('poller', poller)
})

2
packages/nc-gui/utils/iconUtils.ts

@ -1,6 +1,7 @@
import MdiCheckBold from '~icons/mdi/check-bold' import MdiCheckBold from '~icons/mdi/check-bold'
import MdiCropSquare from '~icons/mdi/crop-square' import MdiCropSquare from '~icons/mdi/crop-square'
import MdiCheckCircleOutline from '~icons/mdi/check-circle-outline' import MdiCheckCircleOutline from '~icons/mdi/check-circle-outline'
import MdiCheckboxMarkedCircle from '~icons/mdi/checkbox-marked-circle'
import MdiCheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline' import MdiCheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline'
import MdiStar from '~icons/mdi/star' import MdiStar from '~icons/mdi/star'
import MdiStarOutline from '~icons/mdi/star-outline' import MdiStarOutline from '~icons/mdi/star-outline'
@ -366,6 +367,7 @@ export const iconMap = {
expand: h('span', { class: 'material-symbols' }, 'open_in_full'), expand: h('span', { class: 'material-symbols' }, 'open_in_full'),
shrink: h('span', { class: 'material-symbols' }, 'close_fullscreen'), shrink: h('span', { class: 'material-symbols' }, 'close_fullscreen'),
check: NcCheck, check: NcCheck,
circleCheck: MdiCheckboxMarkedCircle,
acl: h('span', { class: 'material-symbols' }, 'shield'), acl: h('span', { class: 'material-symbols' }, 'shield'),
sync: MsSync, sync: MsSync,
warning: MaterialSymbolsWarningOutlineRounded, warning: MaterialSymbolsWarningOutlineRounded,

47
packages/nocodb/src/Noco.ts

@ -105,35 +105,44 @@ export default class Noco {
// new ExpressAdapter(server), // new ExpressAdapter(server),
); );
nestApp.useWebSocketAdapter(new IoAdapter(httpServer)); if (process.env.NC_WORKER_CONTAINER === 'true') {
if (!process.env.NC_REDIS_URL) {
throw new Error('NC_REDIS_URL is required');
}
process.env.NC_DISABLE_TELE = 'true';
this._httpServer = nestApp.getHttpAdapter().getInstance(); nestApp.init();
this._server = server; } else {
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
nestApp.use(requestIp.mw()); this._httpServer = nestApp.getHttpAdapter().getInstance();
nestApp.use(cookieParser()); this._server = server;
this.initSentry(nestApp); nestApp.use(requestIp.mw());
nestApp.use(cookieParser());
nestApp.useWebSocketAdapter(new IoAdapter(httpServer)); this.initSentry(nestApp);
nestApp.use( nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);
await nestApp.init(); nestApp.use(
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);
const dashboardPath = process.env.NC_DASHBOARD_URL ?? '/dashboard'; await nestApp.init();
server.use(NcToolGui.expressMiddleware(dashboardPath));
server.use(express.static(path.join(__dirname, 'public')));
if (dashboardPath !== '/' && dashboardPath !== '') { const dashboardPath = process.env.NC_DASHBOARD_URL ?? '/dashboard';
server.get('/', (_req, res) => res.redirect(dashboardPath)); server.use(NcToolGui.expressMiddleware(dashboardPath));
} server.use(express.static(path.join(__dirname, 'public')));
this.initSentryErrorHandler(server); if (dashboardPath !== '/' && dashboardPath !== '') {
server.get('/', (_req, res) => res.redirect(dashboardPath));
}
this.initSentryErrorHandler(server);
return nestApp.getHttpAdapter().getInstance(); return nestApp.getHttpAdapter().getInstance();
}
} }
public static get httpServer(): http.Server { public static get httpServer(): http.Server {

40
packages/nocodb/src/controllers/bases.controller.ts

@ -1,14 +1,4 @@
import { import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { BaseReqType } from 'nocodb-sdk'; import { BaseReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard'; import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse'; import { PagedResponseImpl } from '~/helpers/PagedResponse';
@ -68,32 +58,4 @@ export class BasesController {
limit: bases.length, limit: bases.length,
}); });
} }
@Delete('/api/v1/db/meta/projects/:projectId/bases/:baseId')
@Acl('baseDelete')
async baseDelete(@Param('baseId') baseId: string) {
const result = await this.basesService.baseDelete({
baseId,
});
return result;
}
@Post('/api/v1/db/meta/projects/:projectId/bases')
@HttpCode(200)
@Acl('baseCreate')
async baseCreate(
@Param('projectId') projectId: string,
@Body() body: BaseReqType,
) {
const base = await this.basesService.baseCreate({
projectId,
base: body,
});
if (base.isMeta()) {
delete base.config;
}
return base;
}
} }

32
packages/nocodb/src/controllers/meta-diffs.controller.ts

@ -1,11 +1,4 @@
import { import { Controller, Get, Param, UseGuards } from '@nestjs/common';
Controller,
Get,
HttpCode,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { GlobalGuard } from '~/guards/global/global.guard'; import { GlobalGuard } from '~/guards/global/global.guard';
import { MetaDiffsService } from '~/services/meta-diffs.service'; import { MetaDiffsService } from '~/services/meta-diffs.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
@ -32,27 +25,4 @@ export class MetaDiffsController {
projectId, projectId,
}); });
} }
@Post('/api/v1/db/meta/projects/:projectId/meta-diff')
@HttpCode(200)
@Acl('metaDiffSync')
async metaDiffSync(@Param('projectId') projectId: string) {
await this.metaDiffsService.metaDiffSync({ projectId });
return { msg: 'The meta has been synchronized successfully' };
}
@Post('/api/v1/db/meta/projects/:projectId/meta-diff/:baseId')
@HttpCode(200)
@Acl('baseMetaDiffSync')
async baseMetaDiffSync(
@Param('projectId') projectId: string,
@Param('baseId') baseId: string,
) {
await this.metaDiffsService.baseMetaDiffSync({
projectId,
baseId,
});
return { msg: 'The base meta has been synchronized successfully' };
}
} }

3
packages/nocodb/src/interface/Jobs.ts

@ -4,6 +4,9 @@ export enum JobTypes {
DuplicateBase = 'duplicate-base', DuplicateBase = 'duplicate-base',
DuplicateModel = 'duplicate-model', DuplicateModel = 'duplicate-model',
AtImport = 'at-import', AtImport = 'at-import',
MetaSync = 'meta-sync',
BaseCreate = 'base-create',
BaseDelete = 'base-delete',
} }
export enum JobStatus { export enum JobStatus {

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -22,6 +22,7 @@ import * as nc_031_remove_fk_and_add_idx from './v2/nc_031_remove_fk_and_add_idx
import * as nc_033_add_group_by from './v2/nc_033_add_group_by'; import * as nc_033_add_group_by from './v2/nc_033_add_group_by';
import * as nc_034_erd_filter_and_notification from './v2/nc_034_erd_filter_and_notification'; import * as nc_034_erd_filter_and_notification from './v2/nc_034_erd_filter_and_notification';
import * as nc_035_add_username_to_users from './v2/nc_035_add_username_to_users'; import * as nc_035_add_username_to_users from './v2/nc_035_add_username_to_users';
import * as nc_036_base_deleted from './v2/nc_036_base_deleted';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -55,6 +56,7 @@ export default class XcMigrationSourcev2 {
'nc_033_add_group_by', 'nc_033_add_group_by',
'nc_034_erd_filter_and_notification', 'nc_034_erd_filter_and_notification',
'nc_035_add_username_to_users', 'nc_035_add_username_to_users',
'nc_036_base_deleted',
]); ]);
} }
@ -112,6 +114,8 @@ export default class XcMigrationSourcev2 {
return nc_034_erd_filter_and_notification; return nc_034_erd_filter_and_notification;
case 'nc_035_add_username_to_users': case 'nc_035_add_username_to_users':
return nc_035_add_username_to_users; return nc_035_add_username_to_users;
case 'nc_036_base_deleted':
return nc_036_base_deleted;
} }
} }
} }

16
packages/nocodb/src/meta/migrations/v2/nc_036_base_deleted.ts

@ -0,0 +1,16 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.BASES, (table) => {
table.boolean('deleted').defaultTo(false);
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.BASES, (table) => {
table.dropColumn('deleted');
});
};
export { up, down };

108
packages/nocodb/src/models/Base.ts

@ -87,7 +87,7 @@ export default class Base implements BaseType {
); );
// call before reorder to update cache // call before reorder to update cache
const returnBase = await this.get(id, ncMeta); const returnBase = await this.get(id, false, ncMeta);
await this.reorderBases(base.projectId); await this.reorderBases(base.projectId);
@ -100,10 +100,11 @@ export default class Base implements BaseType {
projectId: string; projectId: string;
skipReorder?: boolean; skipReorder?: boolean;
meta?: any; meta?: any;
deleted?: boolean;
}, },
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
) { ) {
const oldBase = await Base.get(baseId, ncMeta); const oldBase = await Base.get(baseId, false, ncMeta);
if (!oldBase) NcError.badRequest('Wrong base id!'); if (!oldBase) NcError.badRequest('Wrong base id!');
@ -123,6 +124,7 @@ export default class Base implements BaseType {
'order', 'order',
'enabled', 'enabled',
'meta', 'meta',
'deleted',
]); ]);
if (updateObj.config) { if (updateObj.config) {
@ -156,7 +158,7 @@ export default class Base implements BaseType {
); );
// call before reorder to update cache // call before reorder to update cache
const returnBase = await this.get(oldBase.id, ncMeta); const returnBase = await this.get(oldBase.id, false, ncMeta);
if (!base.skipReorder) if (!base.skipReorder)
await this.reorderBases(base.projectId, returnBase.id, ncMeta); await this.reorderBases(base.projectId, returnBase.id, ncMeta);
@ -179,6 +181,20 @@ export default class Base implements BaseType {
null, null,
MetaTable.BASES, MetaTable.BASES,
{ {
xcCondition: {
_or: [
{
deleted: {
neq: true,
},
},
{
deleted: {
eq: null,
},
},
],
},
orderBy: { orderBy: {
order: 'asc', order: 'asc',
}, },
@ -193,14 +209,20 @@ export default class Base implements BaseType {
await NocoCache.setList(CacheScope.BASE, [args.projectId], baseDataList); await NocoCache.setList(CacheScope.BASE, [args.projectId], baseDataList);
} }
baseDataList.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)); baseDataList.sort(
(a, b) => (a?.order ?? Infinity) - (b?.order ?? Infinity),
);
return baseDataList?.map((baseData) => { return baseDataList?.map((baseData) => {
return this.castType(baseData); return this.castType(baseData);
}); });
} }
static async get(id: string, ncMeta = Noco.ncMeta): Promise<Base> { static async get(
id: string,
force = false,
ncMeta = Noco.ncMeta,
): Promise<Base> {
let baseData = let baseData =
id && id &&
(await NocoCache.get( (await NocoCache.get(
@ -208,7 +230,29 @@ export default class Base implements BaseType {
CacheGetType.TYPE_OBJECT, CacheGetType.TYPE_OBJECT,
)); ));
if (!baseData) { if (!baseData) {
baseData = await ncMeta.metaGet2(null, null, MetaTable.BASES, id); baseData = await ncMeta.metaGet2(
null,
null,
MetaTable.BASES,
id,
null,
force
? {}
: {
_or: [
{
deleted: {
neq: true,
},
},
{
deleted: {
eq: null,
},
},
],
},
);
if (baseData) { if (baseData) {
baseData.meta = parseMetaProp(baseData, 'meta'); baseData.meta = parseMetaProp(baseData, 'meta');
@ -220,9 +264,29 @@ export default class Base implements BaseType {
} }
static async getByUUID(uuid: string, ncMeta = Noco.ncMeta) { static async getByUUID(uuid: string, ncMeta = Noco.ncMeta) {
const base = await ncMeta.metaGet2(null, null, MetaTable.BASES, { const base = await ncMeta.metaGet2(
erd_uuid: uuid, null,
}); null,
MetaTable.BASES,
{
erd_uuid: uuid,
},
null,
{
_or: [
{
deleted: {
neq: true,
},
},
{
deleted: {
eq: null,
},
},
],
},
);
if (!base) return null; if (!base) return null;
@ -396,6 +460,32 @@ export default class Base implements BaseType {
return await ncMeta.metaDelete(null, null, MetaTable.BASES, this.id); return await ncMeta.metaDelete(null, null, MetaTable.BASES, this.id);
} }
async softDelete(ncMeta = Noco.ncMeta, { force }: { force?: boolean } = {}) {
const bases = await Base.list({ projectId: this.project_id }, ncMeta);
if (bases[0].id === this.id && !force) {
NcError.badRequest('Cannot delete first base');
}
await ncMeta.metaUpdate(
this.project_id,
null,
MetaTable.BASES,
{
deleted: true,
},
this.id,
);
await NocoCache.deepDel(
CacheScope.BASE,
`${CacheScope.BASE}:${this.id}`,
CacheDelDirection.CHILD_TO_PARENT,
);
await NocoCache.del(`${CacheScope.BASE}:${this.id}`);
}
async getModels(ncMeta = Noco.ncMeta) { async getModels(ncMeta = Noco.ncMeta) {
return await Model.list( return await Model.list(
{ project_id: this.project_id, base_id: this.id }, { project_id: this.project_id, base_id: this.id },

18
packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts

@ -3,6 +3,9 @@ import PQueue from 'p-queue';
import Emittery from 'emittery'; import Emittery from 'emittery';
import { DuplicateProcessor } from '../jobs/export-import/duplicate.processor'; import { DuplicateProcessor } from '../jobs/export-import/duplicate.processor';
import { AtImportProcessor } from '../jobs/at-import/at-import.processor'; import { AtImportProcessor } from '../jobs/at-import/at-import.processor';
import { MetaSyncProcessor } from '../jobs/meta-sync/meta-sync.processor';
import { BaseCreateProcessor } from '../jobs/base-create/base-create.processor';
import { BaseDeleteProcessor } from '../jobs/base-delete/base-delete.processor';
import { JobsEventService } from './jobs-event.service'; import { JobsEventService } from './jobs-event.service';
import { JobStatus, JobTypes } from '~/interface/Jobs'; import { JobStatus, JobTypes } from '~/interface/Jobs';
@ -25,6 +28,9 @@ export class QueueService {
private readonly jobsEventService: JobsEventService, private readonly jobsEventService: JobsEventService,
private readonly duplicateProcessor: DuplicateProcessor, private readonly duplicateProcessor: DuplicateProcessor,
private readonly atImportProcessor: AtImportProcessor, private readonly atImportProcessor: AtImportProcessor,
private readonly metaSyncProcessor: MetaSyncProcessor,
private readonly baseCreateProcessor: BaseCreateProcessor,
private readonly baseDeleteProcessor: BaseDeleteProcessor,
) { ) {
this.emitter.on(JobStatus.ACTIVE, (data: { job: Job }) => { this.emitter.on(JobStatus.ACTIVE, (data: { job: Job }) => {
const job = this.queueMemory.find((job) => job.id === data.job.id); const job = this.queueMemory.find((job) => job.id === data.job.id);
@ -66,6 +72,18 @@ export class QueueService {
this: this.atImportProcessor, this: this.atImportProcessor,
fn: this.atImportProcessor.job, fn: this.atImportProcessor.job,
}, },
[JobTypes.MetaSync]: {
this: this.metaSyncProcessor,
fn: this.metaSyncProcessor.job,
},
[JobTypes.BaseCreate]: {
this: this.baseCreateProcessor,
fn: this.baseCreateProcessor.job,
},
[JobTypes.BaseDelete]: {
this: this.baseDeleteProcessor,
fn: this.baseDeleteProcessor.job,
},
}; };
async jobWrapper(job: Job) { async jobWrapper(job: Job) {

2
packages/nocodb/src/modules/jobs/fallback/jobs.service.ts

@ -13,7 +13,7 @@ export class JobsService {
async jobStatus(jobId: string) { async jobStatus(jobId: string) {
return await ( return await (
await this.fallbackQueueService.getJob(jobId) await this.fallbackQueueService.getJob(jobId)
).status; )?.status;
} }
async jobList() { async jobList() {

301
packages/nocodb/src/modules/jobs/jobs.controller.ts

@ -0,0 +1,301 @@
import {
Body,
Controller,
HttpCode,
Inject,
Post,
Request,
Response,
UseGuards,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { customAlphabet } from 'nanoid';
import { ModuleRef } from '@nestjs/core';
import { JobsRedisService } from './redis/jobs-redis.service';
import type { OnModuleInit } from '@nestjs/common';
import { JobStatus } from '~/interface/Jobs';
import { JobEvents } from '~/interface/Jobs';
import { GlobalGuard } from '~/guards/global/global.guard';
import NocoCache from '~/cache/NocoCache';
import { CacheGetType, CacheScope } from '~/utils/globals';
const nanoidv2 = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 14);
const POLLING_INTERVAL = 30000;
@Controller()
@UseGuards(GlobalGuard)
export class JobsController implements OnModuleInit {
jobsRedisService: JobsRedisService;
constructor(
@Inject('JobsService') private readonly jobsService,
private moduleRef: ModuleRef,
) {}
onModuleInit() {
if (process.env.NC_REDIS_JOB_URL) {
this.jobsRedisService = this.moduleRef.get(JobsRedisService);
}
}
private jobRooms = {};
private localJobs = {};
private closedJobs = [];
@Post('/jobs/listen')
@HttpCode(200)
async listen(
@Response() res,
@Request() req,
@Body() body: { _mid: number; data: { id: string } },
) {
const { _mid = 0, data } = body;
const jobId = data.id;
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
res.resId = nanoidv2();
let messages;
if (this.localJobs[jobId]) {
messages = this.localJobs[jobId].messages;
} else {
messages = (
await NocoCache.get(
`${CacheScope.JOBS}:${jobId}:messages`,
CacheGetType.TYPE_OBJECT,
)
)?.messages;
}
const newMessages: any[] = [];
if (messages) {
messages.forEach((m) => {
if (m._mid > _mid) {
newMessages.push(m);
}
});
}
if (newMessages.length > 0) {
res.send(newMessages);
return;
}
if (this.closedJobs.includes(jobId)) {
res.send({
status: 'close',
});
return;
}
if (this.jobRooms[jobId]) {
this.jobRooms[jobId].listeners.push(res);
} else {
this.jobRooms[jobId] = {
listeners: [res],
};
// subscribe to job events
if (this.jobsRedisService) {
this.jobsRedisService.subscribe(jobId, (data) => {
if (this.jobRooms[jobId]) {
this.jobRooms[jobId].listeners.forEach((res) => {
if (!res.headersSent) {
res.send({
status: 'refresh',
});
}
});
}
const cmd = data.cmd;
delete data.cmd;
switch (cmd) {
case JobEvents.STATUS:
if (
[JobStatus.COMPLETED, JobStatus.FAILED].includes(data.status)
) {
this.jobsRedisService.unsubscribe(jobId);
delete this.jobRooms[jobId];
this.closedJobs.push(jobId);
setTimeout(() => {
this.closedJobs = this.closedJobs.filter((j) => j !== jobId);
}, POLLING_INTERVAL * 1.5);
}
break;
}
});
}
}
res.on('close', () => {
if (jobId && this.jobRooms[jobId]?.listeners) {
this.jobRooms[jobId].listeners = this.jobRooms[jobId].listeners.filter(
(r) => r.resId !== res.resId,
);
}
});
setTimeout(() => {
if (!res.headersSent) {
res.send({
status: 'refresh',
});
}
}, POLLING_INTERVAL);
}
@Post('/jobs/status')
async status(@Body() data: { id: string } | any) {
let res: {
id?: string;
status?: JobStatus;
} | null = null;
if (Object.keys(data).every((k) => ['id'].includes(k)) && data?.id) {
const rooms = (await this.jobsService.jobList()).map(
(j) => `jobs-${j.id}`,
);
const room = rooms.find((r) => r === `jobs-${data.id}`);
if (room) {
res.id = data.id;
}
} else {
const job = await this.jobsService.getJobWithData(data);
if (job) {
res = {};
res.id = job.id;
res.status = await this.jobsService.jobStatus(data.id);
}
}
return res;
}
@OnEvent(JobEvents.STATUS)
sendJobStatus(data: { id: string; status: JobStatus; data?: any }): void {
let response;
const jobId = data.id;
if (this.localJobs[jobId]) {
response = {
status: 'update',
data,
_mid: this.localJobs[jobId]._mid,
};
this.localJobs[jobId].messages.push(response);
this.localJobs[jobId]._mid += 1;
// limit to 20 messages
if (this.localJobs[jobId].messages.length > 20) {
this.localJobs[jobId].messages.shift();
}
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, {
messages: this.localJobs[jobId].messages,
});
} else {
response = {
status: 'update',
data,
_mid: 1,
};
this.localJobs[jobId] = {
messages: [response],
_mid: 1,
};
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, {
messages: this.localJobs[jobId].messages,
});
}
if (this.jobRooms[jobId]) {
this.jobRooms[jobId].listeners.forEach((res) => {
if (!res.headersSent) {
res.send(response);
}
});
}
if (process.env.NC_WORKER_CONTAINER === 'true' && this.jobsRedisService) {
this.jobsRedisService.publish(jobId, {
cmd: JobEvents.STATUS,
...data,
});
}
if ([JobStatus.COMPLETED, JobStatus.FAILED].includes(data.status)) {
this.closedJobs.push(jobId);
setTimeout(() => {
this.closedJobs = this.closedJobs.filter((j) => j !== jobId);
}, POLLING_INTERVAL * 1.5);
setTimeout(() => {
delete this.jobRooms[jobId];
delete this.localJobs[jobId];
NocoCache.del(`${CacheScope.JOBS}:${jobId}:messages`);
}, POLLING_INTERVAL);
}
}
@OnEvent(JobEvents.LOG)
sendJobLog(data: { id: string; data: { message: string } }): void {
let response;
const jobId = data.id;
if (this.localJobs[jobId]) {
response = {
status: 'update',
data,
_mid: this.localJobs[jobId]._mid,
};
this.localJobs[jobId].messages.push(response);
this.localJobs[jobId]._mid += 1;
// limit to 20 messages
if (this.localJobs[jobId].messages.length > 20) {
this.localJobs[jobId].messages.shift();
}
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, {
messages: this.localJobs[jobId].messages,
});
} else {
response = {
status: 'update',
data,
_mid: 1,
};
this.localJobs[jobId] = {
messages: [response],
_mid: 1,
};
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, {
messages: this.localJobs[jobId].messages,
});
}
if (this.jobRooms[jobId]) {
this.jobRooms[jobId].listeners.forEach((res) => {
if (!res.headersSent) {
res.send(response);
}
});
}
if (process.env.NC_WORKER_CONTAINER === 'true' && this.jobsRedisService) {
this.jobsRedisService.publish(jobId, {
cmd: JobEvents.LOG,
...data,
});
}
}
}

110
packages/nocodb/src/modules/jobs/jobs.gateway.ts

@ -1,110 +0,0 @@
import {
ConnectedSocket,
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { AuthGuard } from '@nestjs/passport';
import { OnEvent } from '@nestjs/event-emitter';
import { Inject } from '@nestjs/common';
import type { OnModuleInit } from '@nestjs/common';
import type { JobStatus } from '~/interface/Jobs';
import { JobEvents } from '~/interface/Jobs';
const url = new URL(
process.env.NC_PUBLIC_URL ||
`http://localhost:${process.env.PORT || '8080'}/`,
);
let namespace = url.pathname;
namespace += namespace.endsWith('/') ? 'jobs' : '/jobs';
@WebSocketGateway({
cors: {
origin: '*',
allowedHeaders: ['xc-auth'],
credentials: true,
},
namespace,
})
export class JobsGateway implements OnModuleInit {
constructor(@Inject('JobsService') private readonly jobsService) {}
@WebSocketServer()
server: Server;
async onModuleInit() {
this.server.use(async (socket, next) => {
try {
const context = new ExecutionContextHost([socket.handshake as any]);
const guard = new (AuthGuard('jwt'))(context);
await guard.canActivate(context);
} catch {}
next();
});
}
@SubscribeMessage('subscribe')
async subscribe(
@MessageBody()
body: { _id: number; data: { id: string } | any },
@ConnectedSocket() client: Socket,
): Promise<void> {
const { _id, data } = body;
if (Object.keys(data).every((k) => ['id'].includes(k)) && data?.id) {
const rooms = (await this.jobsService.jobList()).map(
(j) => `jobs-${j.id}`,
);
const room = rooms.find((r) => r === `jobs-${data.id}`);
if (room) {
client.join(`jobs-${data.id}`);
client.emit('subscribed', {
_id,
id: data.id,
});
}
} else {
const job = await this.jobsService.getJobWithData(data);
if (job) {
client.join(`jobs-${job.id}`);
client.emit('subscribed', {
_id,
id: job.id,
});
}
}
}
@SubscribeMessage('status')
async status(
@MessageBody() body: { _id: number; data: { id: string } },
@ConnectedSocket() client: Socket,
): Promise<void> {
const { _id, data } = body;
client.emit('status', {
_id,
id: data.id,
status: await this.jobsService.jobStatus(data.id),
});
}
@OnEvent(JobEvents.STATUS)
sendJobStatus(data: { id: string; status: JobStatus; data?: any }): void {
this.server.to(`jobs-${data.id}`).emit('status', {
id: data.id,
status: data.status,
data: data.data,
});
}
@OnEvent(JobEvents.LOG)
sendJobLog(data: { id: string; data: { message: string } }): void {
this.server.to(`jobs-${data.id}`).emit('log', {
id: data.id,
data: data.data,
});
}
}

29
packages/nocodb/src/modules/jobs/jobs.module.ts

@ -1,15 +1,24 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
// Jobs
import { ExportService } from './jobs/export-import/export.service'; import { ExportService } from './jobs/export-import/export.service';
import { ImportService } from './jobs/export-import/import.service'; import { ImportService } from './jobs/export-import/import.service';
import { AtImportController } from './jobs/at-import/at-import.controller'; import { AtImportController } from './jobs/at-import/at-import.controller';
import { AtImportProcessor } from './jobs/at-import/at-import.processor'; import { AtImportProcessor } from './jobs/at-import/at-import.processor';
import { DuplicateController } from './jobs/export-import/duplicate.controller'; import { DuplicateController } from './jobs/export-import/duplicate.controller';
import { DuplicateProcessor } from './jobs/export-import/duplicate.processor'; import { DuplicateProcessor } from './jobs/export-import/duplicate.processor';
import { JobsLogService } from './jobs/jobs-log.service'; import { MetaSyncController } from './jobs/meta-sync/meta-sync.controller';
import { JobsGateway } from './jobs.gateway'; import { MetaSyncProcessor } from './jobs/meta-sync/meta-sync.processor';
import { BaseCreateController } from './jobs/base-create/base-create.controller';
import { BaseCreateProcessor } from './jobs/base-create/base-create.processor';
import { BaseDeleteController } from './jobs/base-delete/base-delete.controller';
import { BaseDeleteProcessor } from './jobs/base-delete/base-delete.processor';
// Redis // Jobs Module Related
import { JobsLogService } from './jobs/jobs-log.service';
// import { JobsGateway } from './jobs.gateway';
import { JobsController } from './jobs.controller';
import { JobsService } from './redis/jobs.service'; import { JobsService } from './redis/jobs.service';
import { JobsRedisService } from './redis/jobs-redis.service'; import { JobsRedisService } from './redis/jobs-redis.service';
import { JobsEventService } from './redis/jobs-event.service'; import { JobsEventService } from './redis/jobs-event.service';
@ -40,12 +49,19 @@ import { GlobalModule } from '~/modules/global/global.module';
: []), : []),
], ],
controllers: [ controllers: [
JobsController,
...(process.env.NC_WORKER_CONTAINER !== 'true' ...(process.env.NC_WORKER_CONTAINER !== 'true'
? [DuplicateController, AtImportController] ? [
DuplicateController,
AtImportController,
MetaSyncController,
BaseCreateController,
BaseDeleteController,
]
: []), : []),
], ],
providers: [ providers: [
...(process.env.NC_WORKER_CONTAINER !== 'true' ? [JobsGateway] : []), ...(process.env.NC_WORKER_CONTAINER !== 'true' ? [] : []),
...(process.env.NC_REDIS_JOB_URL ...(process.env.NC_REDIS_JOB_URL
? [JobsRedisService, JobsEventService] ? [JobsRedisService, JobsEventService]
: [FallbackQueueService, FallbackJobsEventService]), : [FallbackQueueService, FallbackJobsEventService]),
@ -60,6 +76,9 @@ import { GlobalModule } from '~/modules/global/global.module';
ImportService, ImportService,
DuplicateProcessor, DuplicateProcessor,
AtImportProcessor, AtImportProcessor,
MetaSyncProcessor,
BaseCreateProcessor,
BaseDeleteProcessor,
], ],
}) })
export class JobsModule {} export class JobsModule {}

46
packages/nocodb/src/modules/jobs/jobs/base-create/base-create.controller.ts

@ -0,0 +1,46 @@
import {
Body,
Controller,
HttpCode,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { BaseReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { NcError } from '~/helpers/catchError';
import { JobTypes } from '~/interface/Jobs';
@Controller()
@UseGuards(GlobalGuard)
export class BaseCreateController {
constructor(@Inject('JobsService') private readonly jobsService) {}
@Post('/api/v1/db/meta/projects/:projectId/bases')
@HttpCode(200)
@Acl('baseCreate')
async baseCreate(
@Param('projectId') projectId: string,
@Body() body: BaseReqType,
) {
const jobs = await this.jobsService.jobList();
const fnd = jobs.find(
(j) => j.name === JobTypes.BaseCreate && j.data.projectId === projectId,
);
if (fnd) {
NcError.badRequest(
'Another base creation is in progress for this project.',
);
}
const job = await this.jobsService.add(JobTypes.BaseCreate, {
projectId,
base: body,
});
return { id: job.id };
}
}

28
packages/nocodb/src/modules/jobs/jobs/base-create/base-create.processor.ts

@ -0,0 +1,28 @@
import debug from 'debug';
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { JOBS_QUEUE, JobTypes } from '~/interface/Jobs';
import { BasesService } from '~/services/bases.service';
@Processor(JOBS_QUEUE)
export class BaseCreateProcessor {
private readonly debugLog = debug('nc:meta-sync:processor');
constructor(private readonly basesService: BasesService) {}
@Process(JobTypes.BaseCreate)
async job(job: Job) {
const { projectId, base } = job.data;
const createdBase = await this.basesService.baseCreate({
projectId,
base,
});
if (createdBase.isMeta()) {
delete createdBase.config;
}
return createdBase;
}
}

36
packages/nocodb/src/modules/jobs/jobs/base-delete/base-delete.controller.ts

@ -0,0 +1,36 @@
import { Controller, Delete, Inject, Param, UseGuards } from '@nestjs/common';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { NcError } from '~/helpers/catchError';
import { JobTypes } from '~/interface/Jobs';
import { BasesService } from '~/services/bases.service';
@Controller()
@UseGuards(GlobalGuard)
export class BaseDeleteController {
constructor(
@Inject('JobsService') private readonly jobsService,
private readonly basesService: BasesService,
) {}
@Delete('/api/v1/db/meta/projects/:projectId/bases/:baseId')
@Acl('baseDelete')
async baseDelete(@Param('baseId') baseId: string) {
const jobs = await this.jobsService.jobList();
const fnd = jobs.find(
(j) => j.name === JobTypes.BaseDelete && j.data.baseId === baseId,
);
if (fnd) {
NcError.badRequest('There is already a job running to delete this base.');
}
await this.basesService.baseSoftDelete({ baseId });
const job = await this.jobsService.add(JobTypes.BaseDelete, {
baseId,
});
return { id: job.id };
}
}

23
packages/nocodb/src/modules/jobs/jobs/base-delete/base-delete.processor.ts

@ -0,0 +1,23 @@
import debug from 'debug';
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { JOBS_QUEUE, JobTypes } from '~/interface/Jobs';
import { BasesService } from '~/services/bases.service';
@Processor(JOBS_QUEUE)
export class BaseDeleteProcessor {
private readonly debugLog = debug('nc:meta-sync:processor');
constructor(private readonly basesService: BasesService) {}
@Process(JobTypes.BaseDelete)
async job(job: Job) {
const { baseId } = job.data;
await this.basesService.baseDelete({
baseId,
});
return true;
}
}

70
packages/nocodb/src/modules/jobs/jobs/meta-sync/meta-sync.controller.ts

@ -0,0 +1,70 @@
import {
Controller,
HttpCode,
Inject,
Param,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { NcError } from '~/helpers/catchError';
import { JobTypes } from '~/interface/Jobs';
@Controller()
@UseGuards(GlobalGuard)
export class MetaSyncController {
constructor(@Inject('JobsService') private readonly jobsService) {}
@Post('/api/v1/db/meta/projects/:projectId/meta-diff')
@HttpCode(200)
@Acl('metaDiffSync')
async metaDiffSync(@Param('projectId') projectId: string, @Request() req) {
const jobs = await this.jobsService.jobList();
const fnd = jobs.find(
(j) => j.name === JobTypes.MetaSync && j.data.projectId === projectId,
);
if (fnd) {
NcError.badRequest('Meta sync already in progress for this project');
}
const job = await this.jobsService.add(JobTypes.MetaSync, {
projectId,
baseId: 'all',
user: req.user,
});
return { id: job.id };
}
@Post('/api/v1/db/meta/projects/:projectId/meta-diff/:baseId')
@HttpCode(200)
@Acl('baseMetaDiffSync')
async baseMetaDiffSync(
@Param('projectId') projectId: string,
@Param('baseId') baseId: string,
@Request() req,
) {
const jobs = await this.jobsService.jobList();
const fnd = jobs.find(
(j) =>
j.name === JobTypes.MetaSync &&
j.data.projectId === projectId &&
(j.data.baseId === baseId || j.data.baseId === 'all'),
);
if (fnd) {
NcError.badRequest('Meta sync already in progress for this project');
}
const job = await this.jobsService.add(JobTypes.MetaSync, {
projectId,
baseId,
user: req.user,
});
return { id: job.id };
}
}

30
packages/nocodb/src/modules/jobs/jobs/meta-sync/meta-sync.processor.ts

@ -0,0 +1,30 @@
import debug from 'debug';
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { JOBS_QUEUE, JobTypes } from '~/interface/Jobs';
import { MetaDiffsService } from '~/services/meta-diffs.service';
@Processor(JOBS_QUEUE)
export class MetaSyncProcessor {
private readonly debugLog = debug('nc:meta-sync:processor');
constructor(private readonly metaDiffsService: MetaDiffsService) {}
@Process(JobTypes.MetaSync)
async job(job: Job) {
const info: {
projectId: string;
baseId: string;
user: any;
} = job.data;
if (info.baseId === 'all') {
await this.metaDiffsService.metaDiffSync({ projectId: info.projectId });
} else {
await this.metaDiffsService.baseMetaDiffSync({
projectId: info.projectId,
baseId: info.baseId,
});
}
}
}

91
packages/nocodb/src/modules/jobs/redis/jobs-event.service.ts

@ -6,31 +6,19 @@ import {
} from '@nestjs/bull'; } from '@nestjs/bull';
import { Job } from 'bull'; import { Job } from 'bull';
import boxen from 'boxen'; import boxen from 'boxen';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { JobsRedisService } from './jobs-redis.service';
import { JobEvents, JOBS_QUEUE, JobStatus } from '~/interface/Jobs'; import { JobEvents, JOBS_QUEUE, JobStatus } from '~/interface/Jobs';
@Processor(JOBS_QUEUE) @Processor(JOBS_QUEUE)
export class JobsEventService { export class JobsEventService {
constructor( constructor(private eventEmitter: EventEmitter2) {}
private jobsRedisService: JobsRedisService,
private eventEmitter: EventEmitter2,
) {}
@OnQueueActive() @OnQueueActive()
onActive(job: Job) { onActive(job: Job) {
if (process.env.NC_WORKER_CONTAINER === 'true') { this.eventEmitter.emit(JobEvents.STATUS, {
this.jobsRedisService.publish(`jobs-${job.id.toString()}`, { id: job.id.toString(),
cmd: JobEvents.STATUS, status: JobStatus.ACTIVE,
id: job.id.toString(), });
status: JobStatus.ACTIVE,
});
} else {
this.eventEmitter.emit(JobEvents.STATUS, {
id: job.id.toString(),
status: JobStatus.ACTIVE,
});
}
} }
@OnQueueFailed() @OnQueueFailed()
@ -46,62 +34,25 @@ export class JobsEventService {
), ),
); );
if (process.env.NC_WORKER_CONTAINER === 'true') { this.eventEmitter.emit(JobEvents.STATUS, {
this.jobsRedisService.publish(`jobs-${job.id.toString()}`, { id: job.id.toString(),
cmd: JobEvents.STATUS, status: JobStatus.FAILED,
id: job.id.toString(), data: {
status: JobStatus.FAILED, error: {
data: { message: error?.message,
error: {
message: error?.message,
},
}, },
}); },
} else { });
this.jobsRedisService.unsubscribe(`jobs-${job.id.toString()}`);
this.eventEmitter.emit(JobEvents.STATUS, {
id: job.id.toString(),
status: JobStatus.FAILED,
data: {
error: {
message: error?.message,
},
},
});
}
} }
@OnQueueCompleted() @OnQueueCompleted()
onCompleted(job: Job, data: any) { onCompleted(job: Job, data: any) {
if (process.env.NC_WORKER_CONTAINER === 'true') { this.eventEmitter.emit(JobEvents.STATUS, {
this.jobsRedisService.publish(`jobs-${job.id.toString()}`, { id: job.id.toString(),
cmd: JobEvents.STATUS, status: JobStatus.COMPLETED,
id: job.id.toString(), data: {
status: JobStatus.COMPLETED, result: data,
data: { },
result: data, });
},
});
} else {
this.jobsRedisService.unsubscribe(`jobs-${job.id.toString()}`);
this.eventEmitter.emit(JobEvents.STATUS, {
id: job.id.toString(),
status: JobStatus.COMPLETED,
data: {
result: data,
},
});
}
}
@OnEvent(JobEvents.LOG)
onLog(data: { id: string; data: { message: string } }) {
if (process.env.NC_WORKER_CONTAINER === 'true') {
this.jobsRedisService.publish(`jobs-${data.id}`, {
cmd: JobEvents.LOG,
id: data.id,
data: data.data,
});
}
} }
} }

31
packages/nocodb/src/modules/jobs/redis/jobs.service.ts

@ -1,18 +1,12 @@
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { JobsRedisService } from './jobs-redis.service';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import { JobEvents, JOBS_QUEUE, JobStatus } from '~/interface/Jobs'; import { JOBS_QUEUE, JobStatus } from '~/interface/Jobs';
@Injectable() @Injectable()
export class JobsService implements OnModuleInit { export class JobsService implements OnModuleInit {
constructor( constructor(@InjectQueue(JOBS_QUEUE) private readonly jobsQueue: Queue) {}
@InjectQueue(JOBS_QUEUE) private readonly jobsQueue: Queue,
private jobsRedisService: JobsRedisService,
private eventEmitter: EventEmitter2,
) {}
// pause primary instance queue // pause primary instance queue
async onModuleInit() { async onModuleInit() {
@ -28,31 +22,14 @@ export class JobsService implements OnModuleInit {
// if there is no worker and primary instance queue is paused, resume it // if there is no worker and primary instance queue is paused, resume it
// if there is any worker and primary instance queue is not paused, pause it // if there is any worker and primary instance queue is not paused, pause it
if (workerCount < 1 && localWorkerPaused) { if (workerCount === 1 && localWorkerPaused) {
await this.jobsQueue.resume(true); await this.jobsQueue.resume(true);
} else if (workerCount > 0 && !localWorkerPaused) { } else if (workerCount > 1 && !localWorkerPaused) {
await this.jobsQueue.pause(true); await this.jobsQueue.pause(true);
} }
const job = await this.jobsQueue.add(name, data); const job = await this.jobsQueue.add(name, data);
// subscribe to job events
this.jobsRedisService.subscribe(`jobs-${job.id.toString()}`, (data) => {
const cmd = data.cmd;
delete data.cmd;
switch (cmd) {
case JobEvents.STATUS:
this.eventEmitter.emit(JobEvents.STATUS, data);
if ([JobStatus.COMPLETED, JobStatus.FAILED].includes(data.status)) {
this.jobsRedisService.unsubscribe(`jobs-${data.id.toString()}`);
}
break;
case JobEvents.LOG:
this.eventEmitter.emit(JobEvents.LOG, data);
break;
}
});
return job; return job;
} }

2
packages/nocodb/src/modules/metas/metas.module.ts

@ -179,6 +179,8 @@ export const metaModuleMetadata = {
AttachmentsService, AttachmentsService,
ProjectUsersService, ProjectUsersService,
HooksService, HooksService,
MetaDiffsService,
BasesService,
], ],
}; };

12
packages/nocodb/src/run/cloud.ts

@ -18,8 +18,12 @@ server.use(
server.set('view engine', 'ejs'); server.set('view engine', 'ejs');
(async () => { (async () => {
const httpServer = server.listen(process.env.PORT || 8080, () => { if (process.env.NC_WORKER_CONTAINER === 'true') {
console.log(`App started successfully.\nVisit -> ${Noco.dashboardUrl}`); await Noco.init({}, null, null);
}); } else {
server.use(await Noco.init({}, httpServer, server)); const httpServer = server.listen(process.env.PORT || 8080, async () => {
console.log(`App started successfully.\nVisit -> ${Noco.dashboardUrl}`);
server.use(await Noco.init({}, httpServer, server));
});
}
})().catch((e) => console.log(e)); })().catch((e) => console.log(e));

10
packages/nocodb/src/run/docker.ts

@ -28,7 +28,11 @@ process.env[`DEBUG`] = 'xc*';
// })().catch((e) => console.log(e)); // })().catch((e) => console.log(e));
(async () => { (async () => {
const httpServer = server.listen(process.env.PORT || 8080, async () => { if (process.env.NC_WORKER_CONTAINER === 'true') {
server.use(await Noco.init({}, httpServer, server)); await Noco.init({}, null, null);
}); } else {
const httpServer = server.listen(process.env.PORT || 8080, async () => {
server.use(await Noco.init({}, httpServer, server));
});
}
})().catch((e) => console.log(e)); })().catch((e) => console.log(e));

10
packages/nocodb/src/run/dockerEntry.ts

@ -13,7 +13,11 @@ server.use(cors());
server.set('view engine', 'ejs'); server.set('view engine', 'ejs');
(async () => { (async () => {
const httpServer = server.listen(process.env.PORT || 8080, async () => { if (process.env.NC_WORKER_CONTAINER === 'true') {
server.use(await Noco.init({}, httpServer, server)); await Noco.init({}, null, null);
}); } else {
const httpServer = server.listen(process.env.PORT || 8080, async () => {
server.use(await Noco.init({}, httpServer, server));
});
}
})().catch((e) => console.log(e)); })().catch((e) => console.log(e));

10
packages/nocodb/src/run/dockerRunMysql.ts

@ -31,7 +31,11 @@ process.env[`NC_DB`] = `mysql2://localhost:3306?u=root&p=password&d=${metaDb}`;
// process.env[`DEBUG`] = 'xc*'; // process.env[`DEBUG`] = 'xc*';
(async () => { (async () => {
const httpServer = server.listen(process.env.PORT || 8080, async () => { if (process.env.NC_WORKER_CONTAINER === 'true') {
server.use(await Noco.init({}, httpServer, server)); await Noco.init({}, null, null);
}); } else {
const httpServer = server.listen(process.env.PORT || 8080, async () => {
server.use(await Noco.init({}, httpServer, server));
});
}
})().catch((e) => console.log(e)); })().catch((e) => console.log(e));

10
packages/nocodb/src/run/dockerRunPG.ts

@ -30,7 +30,11 @@ process.env[`NC_DB`] = `pg://localhost:5432?u=postgres&p=password&d=${metaDb}`;
// process.env[`DEBUG`] = 'xc*'; // process.env[`DEBUG`] = 'xc*';
(async () => { (async () => {
const httpServer = server.listen(process.env.PORT || 8080, async () => { if (process.env.NC_WORKER_CONTAINER === 'true') {
server.use(await Noco.init({}, httpServer, server)); await Noco.init({}, null, null);
}); } else {
const httpServer = server.listen(process.env.PORT || 8080, async () => {
server.use(await Noco.init({}, httpServer, server));
});
}
})().catch((e) => console.log(e)); })().catch((e) => console.log(e));

10
packages/nocodb/src/run/dockerRunPG_CyQuick.ts

@ -24,7 +24,11 @@ process.env[
//process.env[`DEBUG`] = 'xc*'; //process.env[`DEBUG`] = 'xc*';
(async () => { (async () => {
const httpServer = server.listen(process.env.PORT || 8080, async () => { if (process.env.NC_WORKER_CONTAINER === 'true') {
server.use(await Noco.init({}, httpServer, server)); await Noco.init({}, null, null);
}); } else {
const httpServer = server.listen(process.env.PORT || 8080, async () => {
server.use(await Noco.init({}, httpServer, server));
});
}
})().catch((e) => console.log(e)); })().catch((e) => console.log(e));

12
packages/nocodb/src/run/local.ts

@ -17,8 +17,12 @@ server.use(
server.set('view engine', 'ejs'); server.set('view engine', 'ejs');
(async () => { (async () => {
const httpServer = server.listen(process.env.PORT || 8080, () => { if (process.env.NC_WORKER_CONTAINER === 'true') {
console.log(`App started successfully.\nVisit -> ${Noco.dashboardUrl}`); await Noco.init({}, null, null);
}); } else {
server.use(await Noco.init({}, httpServer, server)); const httpServer = server.listen(process.env.PORT || 8080, async () => {
console.log(`App started successfully.\nVisit -> ${Noco.dashboardUrl}`);
server.use(await Noco.init({}, httpServer, server));
});
}
})().catch((e) => console.log(e)); })().catch((e) => console.log(e));

48
packages/nocodb/src/schema/swagger.json

@ -16227,6 +16227,54 @@
} }
] ]
} }
},
"/jobs/listen": {
"post": {
"summary": "Jobs Listen",
"operationId": "jobs-listen",
"description": "Listen for job events",
"tags": [
"Jobs"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"/jobs/status": {
"post": {
"summary": "Jobs Status",
"operationId": "jobs-status",
"description": "Get job status",
"tags": [
"Jobs"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
} }
}, },
"components": { "components": {

25
packages/nocodb/src/services/bases.service.ts

@ -6,6 +6,7 @@ import { populateMeta, validatePayload } from '~/helpers';
import { populateRollupColumnAndHideLTAR } from '~/helpers/populateMeta'; import { populateRollupColumnAndHideLTAR } from '~/helpers/populateMeta';
import { syncBaseMigration } from '~/helpers/syncMigration'; import { syncBaseMigration } from '~/helpers/syncMigration';
import { Base, Project } from '~/models'; import { Base, Project } from '~/models';
import { NcError } from '~/helpers/catchError';
@Injectable() @Injectable()
export class BasesService { export class BasesService {
@ -51,11 +52,25 @@ export class BasesService {
} }
async baseDelete(param: { baseId: string }) { async baseDelete(param: { baseId: string }) {
const base = await Base.get(param.baseId); try {
await base.delete(); const base = await Base.get(param.baseId, true);
this.appHooksService.emit(AppEvents.BASE_DELETE, { await base.delete();
base, this.appHooksService.emit(AppEvents.BASE_DELETE, {
}); base,
});
} catch (e) {
NcError.badRequest(e);
}
return true;
}
async baseSoftDelete(param: { baseId: string }) {
try {
const base = await Base.get(param.baseId);
await base.softDelete();
} catch (e) {
NcError.badRequest(e);
}
return true; return true;
} }

2
packages/nocodb/src/services/columns.service.ts

@ -1266,7 +1266,7 @@ export class ColumnsService {
), ),
); );
const base = await reuseOrSave('base', reuse, async () => const base = await reuseOrSave('base', reuse, async () =>
Base.get(table.base_id, ncMeta), Base.get(table.base_id, false, ncMeta),
); );
const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () => const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>

287
packages/nocodb/src/services/meta-diffs.service.ts

@ -642,254 +642,10 @@ export class MetaDiffsService {
return changes; return changes;
} }
async metaDiffSync(param: { projectId: string }) { async syncBaseMeta(project: Project, base: Base, throwOnFail = false) {
const project = await Project.getWithInfo(param.projectId);
for (const base of project.bases) {
// skip if metadb base
if (base.is_meta) continue;
const virtualColumnInsert: Array<() => Promise<void>> = [];
// @ts-ignore
const sqlClient = await NcConnectionMgrv2.getSqlClient(base);
const changes = await this.getMetaDiff(sqlClient, project, base);
/* Get all relations */
// const relations = (await sqlClient.relationListAll())?.data?.list;
for (const { table_name, detectedChanges } of changes) {
// reorder changes to apply relation remove changes
// before column remove to avoid foreign key constraint error
detectedChanges.sort((a, b) => {
return (
applyChangesPriorityOrder.indexOf(b.type) -
applyChangesPriorityOrder.indexOf(a.type)
);
});
for (const change of detectedChanges) {
switch (change.type) {
case MetaDiffType.TABLE_NEW:
{
const columns = (
await sqlClient.columnList({
tn: table_name,
schema: base.getConfig()?.schema,
})
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultDisplayValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(
table_name,
base.is_meta ? project.prefix : '',
base,
),
type: ModelTypes.TABLE,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
fk_model_id: model.id,
...column,
title: getColumnNameAlias(column.column_name, base),
});
}
}
break;
case MetaDiffType.VIEW_NEW:
{
const columns = (
await sqlClient.columnList({
tn: table_name,
schema: base.getConfig()?.schema,
})
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultDisplayValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, project.prefix, base),
type: ModelTypes.VIEW,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
fk_model_id: model.id,
...column,
title: getColumnNameAlias(column.column_name, base),
});
}
}
break;
case MetaDiffType.TABLE_REMOVE:
case MetaDiffType.VIEW_REMOVE:
{
await change.model.delete();
}
break;
case MetaDiffType.TABLE_COLUMN_ADD:
case MetaDiffType.VIEW_COLUMN_ADD:
{
const columns = (
await sqlClient.columnList({
tn: table_name,
schema: base.getConfig()?.schema,
})
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
const column = columns.find((c) => c.cn === change.cn);
column.uidt = getColumnUiType(base, column);
//todo: inflection
column.title = getColumnNameAlias(column.cn, base);
await Column.insert({ fk_model_id: change.id, ...column });
}
// update old
// populateParams.tableNames.push({ tn });
// populateParams.oldMetas[tn] = oldMetas.find(m => m.tn === tn);
break;
case MetaDiffType.TABLE_COLUMN_TYPE_CHANGE:
case MetaDiffType.VIEW_COLUMN_TYPE_CHANGE:
{
const columns = (
await sqlClient.columnList({
tn: table_name,
schema: base.getConfig()?.schema,
})
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
const column = columns.find((c) => c.cn === change.cn);
const metaFact = ModelXcMetaFactory.create(
{ client: base.type },
{},
);
column.uidt = metaFact.getUIDataType(column);
column.title = change.column.title;
await Column.update(change.column.id, column);
}
break;
case MetaDiffType.TABLE_COLUMN_PROPS_CHANGED:
{
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
const colMeta = columns.find((c) => c.cn === change.cn);
if (!colMeta) break;
const { pk, ai, rqd, un, unique } = colMeta;
await Column.update(change.column.id, {
pk,
ai,
rqd,
un,
unique,
});
}
break;
case MetaDiffType.TABLE_COLUMN_REMOVE:
case MetaDiffType.VIEW_COLUMN_REMOVE:
await change.column.delete();
break;
case MetaDiffType.TABLE_RELATION_REMOVE:
case MetaDiffType.TABLE_VIRTUAL_M2M_REMOVE:
await change.column.delete();
break;
case MetaDiffType.TABLE_RELATION_ADD:
{
virtualColumnInsert.push(async () => {
const parentModel = await Model.getByIdOrName({
project_id: base.project_id,
base_id: base.id,
table_name: change.rtn,
});
const childModel = await Model.getByIdOrName({
project_id: base.project_id,
base_id: base.id,
table_name: change.tn,
});
const parentCol = await parentModel
.getColumns()
.then((cols) =>
cols.find((c) => c.column_name === change.rcn),
);
const childCol = await childModel
.getColumns()
.then((cols) =>
cols.find((c) => c.column_name === change.cn),
);
await Column.update(childCol.id, {
...childCol,
uidt: UITypes.ForeignKey,
system: true,
});
if (change.relationType === RelationTypes.BELONGS_TO) {
const title = getUniqueColumnAliasName(
childModel.columns,
`${parentModel.title || parentModel.table_name}`,
);
await Column.insert<LinkToAnotherRecordColumn>({
uidt: UITypes.LinkToAnotherRecord,
title,
fk_model_id: childModel.id,
fk_related_model_id: parentModel.id,
type: RelationTypes.BELONGS_TO,
fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id,
virtual: false,
fk_index_name: change.cstn,
});
} else if (change.relationType === RelationTypes.HAS_MANY) {
const title = getUniqueColumnAliasName(
childModel.columns,
pluralize(childModel.title || childModel.table_name),
);
await Column.insert<LinkToAnotherRecordColumn>({
uidt: UITypes.Links,
title,
fk_model_id: parentModel.id,
fk_related_model_id: childModel.id,
type: RelationTypes.HAS_MANY,
fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id,
virtual: false,
fk_index_name: change.cstn,
meta: {
plural: pluralize(childModel.title),
singular: singularize(childModel.title),
},
});
}
});
}
break;
}
}
}
await NcHelp.executeOperations(virtualColumnInsert, base.type);
// populate m2m relations
await this.extractAndGenerateManyToManyRelations(await base.getModels());
}
this.appHooksService.emit(AppEvents.META_DIFF_SYNC, {
project,
});
return true;
}
async baseMetaDiffSync(param: { projectId: string; baseId: string }) {
const project = await Project.getWithInfo(param.projectId);
const base = await Base.get(param.baseId);
if (base.is_meta) { if (base.is_meta) {
NcError.badRequest('Cannot sync meta base'); if (throwOnFail) NcError.badRequest('Cannot sync meta base');
return;
} }
const virtualColumnInsert: Array<() => Promise<void>> = []; const virtualColumnInsert: Array<() => Promise<void>> = [];
@ -902,6 +658,15 @@ export class MetaDiffsService {
// const relations = (await sqlClient.relationListAll())?.data?.list; // const relations = (await sqlClient.relationListAll())?.data?.list;
for (const { table_name, detectedChanges } of changes) { for (const { table_name, detectedChanges } of changes) {
// reorder changes to apply relation remove changes
// before column remove to avoid foreign key constraint error
detectedChanges.sort((a, b) => {
return (
applyChangesPriorityOrder.indexOf(b.type) -
applyChangesPriorityOrder.indexOf(a.type)
);
});
for (const change of detectedChanges) { for (const change of detectedChanges) {
switch (change.type) { switch (change.type) {
case MetaDiffType.TABLE_NEW: case MetaDiffType.TABLE_NEW:
@ -1076,13 +841,14 @@ export class MetaDiffsService {
fk_parent_column_id: parentCol.id, fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id, fk_child_column_id: childCol.id,
virtual: false, virtual: false,
fk_index_name: change.cstn,
}); });
} else if (change.relationType === RelationTypes.HAS_MANY) { } else if (change.relationType === RelationTypes.HAS_MANY) {
const title = getUniqueColumnAliasName( const title = getUniqueColumnAliasName(
childModel.columns, childModel.columns,
pluralize(childModel.title || childModel.table_name), pluralize(childModel.title || childModel.table_name),
); );
await Column.insert<LinksColumn>({ await Column.insert<LinkToAnotherRecordColumn>({
uidt: UITypes.Links, uidt: UITypes.Links,
title, title,
fk_model_id: parentModel.id, fk_model_id: parentModel.id,
@ -1091,6 +857,11 @@ export class MetaDiffsService {
fk_parent_column_id: parentCol.id, fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id, fk_child_column_id: childCol.id,
virtual: false, virtual: false,
fk_index_name: change.cstn,
meta: {
plural: pluralize(childModel.title),
singular: singularize(childModel.title),
},
}); });
} }
}); });
@ -1104,6 +875,26 @@ export class MetaDiffsService {
// populate m2m relations // populate m2m relations
await this.extractAndGenerateManyToManyRelations(await base.getModels()); await this.extractAndGenerateManyToManyRelations(await base.getModels());
}
async metaDiffSync(param: { projectId: string }) {
const project = await Project.getWithInfo(param.projectId);
for (const base of project.bases) {
await this.syncBaseMeta(project, base);
}
this.appHooksService.emit(AppEvents.META_DIFF_SYNC, {
project,
});
return true;
}
async baseMetaDiffSync(param: { projectId: string; baseId: string }) {
const project = await Project.getWithInfo(param.projectId);
const base = await Base.get(param.baseId);
await this.syncBaseMeta(project, base, true);
this.appHooksService.emit(AppEvents.META_DIFF_SYNC, { this.appHooksService.emit(AppEvents.META_DIFF_SYNC, {
project, project,

1
packages/nocodb/src/utils/globals.ts

@ -150,6 +150,7 @@ export enum CacheScope {
USER_PROJECT = 'userProject', USER_PROJECT = 'userProject',
DASHBOARD_PROJECT_DB_PROJECT_LINKING = 'dashboardProjectDBProjectLinking', DASHBOARD_PROJECT_DB_PROJECT_LINKING = 'dashboardProjectDBProjectLinking',
SINGLE_QUERY = 'singleQuery', SINGLE_QUERY = 'singleQuery',
JOBS = 'nc_jobs',
} }
export enum CacheGetType { export enum CacheGetType {

Loading…
Cancel
Save