mirror of
https://github.com/nocodb/nocodb.git
synced 2026-10-28 08:35:22 +08:00
1 parent
f6c6132354
commit
907302e1da
119 files changed
+2552
-506
No files matched your search
@@ -116,3 +116,10 @@ antd.variable.css
|
||||
.playwright-mcp
|
||||
.playwright-cli
|
||||
.claude/skills/nocodb-dev-api/.credentials.json
|
||||
|
||||
# nc-dev
|
||||
.nc-dev-config.json
|
||||
.nc-dev-registry.json
|
||||
.nc-dev-proxy.pid
|
||||
.nc-dev-proxy.log
|
||||
.nc-dev-logs/
|
||||
@@ -163,4 +163,5 @@ const _ = (window as any).ResizeObserver
|
||||
|
||||
<LazyChatPanel />
|
||||
<LazyChatFab />
|
||||
<LazySandboxChangelogDrawer />
|
||||
</template>
|
||||
@@ -272,7 +272,10 @@ loadLicense()
|
||||
</div>
|
||||
|
||||
<!-- Buy / Manage License card -->
|
||||
<div v-if="isSelfServeLicensePurchaseEnabled" class="flex flex-col border-1 rounded-2xl border-nc-border-gray-medium p-6 gap-4">
|
||||
<div
|
||||
v-if="isSelfServeLicensePurchaseEnabled"
|
||||
class="flex flex-col border-1 rounded-2xl border-nc-border-gray-medium p-6 gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-bold text-base text-nc-content-gray">
|
||||
{{ licenseStatus === 'none' ? $t('labels.buyLicense') : $t('labels.manageLicense') }}
|
||||
|
||||
@@ -398,17 +398,20 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
|
||||
continue
|
||||
}
|
||||
|
||||
const apiPromise = isPublic.value
|
||||
? () => fetchSharedViewAttachment(columnId!, rowId!, src)
|
||||
: () =>
|
||||
$api.dbDataTableRow.attachmentDownload(modelId!, columnId!, rowId!, {
|
||||
urlOrPath: src,
|
||||
})
|
||||
|
||||
let res
|
||||
|
||||
try {
|
||||
res = await apiPromise()
|
||||
if (isPublic.value) {
|
||||
res = await fetchSharedViewAttachment(columnId!, rowId!, src)
|
||||
} else {
|
||||
res = await $api.internal.getOperation(meta.value!.fk_workspace_id!, meta.value!.base_id!, {
|
||||
operation: 'attachmentDownload',
|
||||
modelId: modelId!,
|
||||
columnId: columnId!,
|
||||
rowId: rowId!,
|
||||
urlOrPath: src,
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!res) {
|
||||
@@ -490,22 +493,27 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
|
||||
const rowId = extractPkFromRow(unref(row).row, meta.value.columns!)
|
||||
const src = item.url || item.path
|
||||
if (modelId && columnId && rowId && src) {
|
||||
const apiPromise = isPublic.value
|
||||
? () => fetchSharedViewAttachment(columnId, rowId, src)
|
||||
: () =>
|
||||
$api.dbDataTableRow.attachmentDownload(modelId, columnId, rowId, {
|
||||
urlOrPath: src,
|
||||
})
|
||||
let res
|
||||
|
||||
await apiPromise().then((res) => {
|
||||
if (res?.path) {
|
||||
window.open(`${baseURL}/${res.path}`, '_self')
|
||||
} else if (res?.url) {
|
||||
window.open(res.url, '_self')
|
||||
} else {
|
||||
message.error('Failed to download file')
|
||||
}
|
||||
})
|
||||
if (isPublic.value) {
|
||||
res = await fetchSharedViewAttachment(columnId, rowId, src)
|
||||
} else {
|
||||
res = await $api.internal.getOperation(meta.value.fk_workspace_id!, meta.value.base_id!, {
|
||||
operation: 'attachmentDownload',
|
||||
modelId,
|
||||
columnId,
|
||||
rowId,
|
||||
urlOrPath: src,
|
||||
})
|
||||
}
|
||||
|
||||
if (res?.path) {
|
||||
window.open(`${baseURL}/${res.path}`, '_self')
|
||||
} else if (res?.url) {
|
||||
window.open(res.url, '_self')
|
||||
} else {
|
||||
message.error('Failed to download file')
|
||||
}
|
||||
} else {
|
||||
message.error('Failed to download file')
|
||||
}
|
||||
|
||||
@@ -205,6 +205,16 @@ onMounted(() => {
|
||||
/>
|
||||
</template>
|
||||
</NcSidebarMenuItem>
|
||||
<NcSidebarMenuItem
|
||||
v-if="isEeUI && !isMobileMode"
|
||||
v-e="['c:settings:base:variables']"
|
||||
icon="ncSettings"
|
||||
data-testid="base-variables"
|
||||
:active="activeBaseSettingsTab === 'variables'"
|
||||
@click="navigateToBaseSettings('variables')"
|
||||
>
|
||||
{{ $t('title.baseVariables') }}
|
||||
</NcSidebarMenuItem>
|
||||
<NcSidebarMenuItem
|
||||
v-if="
|
||||
isEeUI &&
|
||||
|
||||
@@ -40,6 +40,8 @@ const viewportWidth = ref(window.innerWidth)
|
||||
|
||||
const { isPanelExpanded: isChatPanelExpanded, isFullScreen: isChatFullScreen } = useChatPanel()
|
||||
|
||||
const { isDrawerOpen: isSandboxDrawerOpen } = useSandboxChangelog()
|
||||
|
||||
const { isRtl } = useRtl()
|
||||
|
||||
const isChatToggling = ref(false)
|
||||
@@ -162,7 +164,8 @@ function onWindowResize(e?: any): void {
|
||||
if (isChatToggling.value) return
|
||||
|
||||
const chatPanelOffset = parseFloat(document.documentElement.style.getPropertyValue('--nc-chat-panel-offset')) || 0
|
||||
viewportWidth.value = window.innerWidth - chatPanelOffset
|
||||
const sandboxDrawerOffset = parseFloat(document.documentElement.style.getPropertyValue('--nc-sandbox-drawer-offset')) || 0
|
||||
viewportWidth.value = window.innerWidth - chatPanelOffset - sandboxDrawerOffset
|
||||
|
||||
if (!e && isLeftSidebarOpen.value && !sideBarSize.value.current && !isMobileMode.value) {
|
||||
currentSidebarSize.value = sideBarSize.value.old
|
||||
@@ -237,17 +240,18 @@ function onResize(widthPercent: any) {
|
||||
|
||||
const contentWidthStyle = computed(() => ({
|
||||
width: isMiniSidebarVisible.value
|
||||
? 'calc(100vw - var(--mini-sidebar-width) - var(--nc-chat-panel-offset, 0px))'
|
||||
: 'calc(100vw - var(--nc-chat-panel-offset, 0px))',
|
||||
? 'calc(100vw - var(--mini-sidebar-width) - var(--nc-chat-panel-offset, 0px) - var(--nc-sandbox-drawer-offset, 0px))'
|
||||
: 'calc(100vw - var(--nc-chat-panel-offset, 0px) - var(--nc-sandbox-drawer-offset, 0px))',
|
||||
}))
|
||||
|
||||
watch(isChatPanelExpanded, () => {
|
||||
watch([isChatPanelExpanded, isSandboxDrawerOpen], () => {
|
||||
isChatToggling.value = true
|
||||
document.documentElement.classList.add('nc-chat-toggling')
|
||||
|
||||
nextTick(() => {
|
||||
const offset = parseFloat(document.documentElement.style.getPropertyValue('--nc-chat-panel-offset')) || 0
|
||||
viewportWidth.value = window.innerWidth - offset
|
||||
const chatOffset = parseFloat(document.documentElement.style.getPropertyValue('--nc-chat-panel-offset')) || 0
|
||||
const sandboxOffset = parseFloat(document.documentElement.style.getPropertyValue('--nc-sandbox-drawer-offset')) || 0
|
||||
viewportWidth.value = window.innerWidth - chatOffset - sandboxOffset
|
||||
|
||||
const containerWidth = isMiniSidebarVisible.value ? viewportWidth.value - miniSidebarWidth.value : viewportWidth.value
|
||||
if (containerWidth > 0) {
|
||||
|
||||
@@ -8,7 +8,7 @@ const baseStore = useBase()
|
||||
const basesStore = useBases()
|
||||
const { base } = storeToRefs(baseStore)
|
||||
|
||||
const { navigateToProject } = useGlobal()
|
||||
const route = useRoute()
|
||||
|
||||
const _projectId = inject(ProjectIdInj, undefined)
|
||||
|
||||
@@ -35,13 +35,7 @@ const migrateToV3 = async () => {
|
||||
|
||||
isModalVisible.value = false
|
||||
|
||||
await navigateToProject({
|
||||
workspaceId: base.value?.fk_workspace_id,
|
||||
baseId: baseId.value,
|
||||
query: {
|
||||
page: 'overview',
|
||||
},
|
||||
})
|
||||
await navigateTo(`/${route.params.typeOrId}/${baseId.value}/settings/settings?tab=baseType`)
|
||||
} catch (e: any) {
|
||||
message.error(await extractSdkResponseErrorMsg(e))
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NcSpanHidden />
|
||||
</template>
|
||||
@@ -103,6 +103,12 @@ const workspaceStore = useWorkspace()
|
||||
const baseStore = useBase()
|
||||
const { baseId: activeBaseId } = storeToRefs(baseStore)
|
||||
|
||||
const basesStore = useBases()
|
||||
|
||||
const isSandboxProduction = computed(() => !!basesStore.bases.get(props.baseId)?.is_sandbox_production)
|
||||
|
||||
const isSandbox = computed(() => !!basesStore.bases.get(props.baseId)?.is_sandbox)
|
||||
|
||||
const { blockCalendarRange, getPlanTitle, showEEFeatures, getFeature } = useEeConfig()
|
||||
|
||||
const isPersonalViewFeatureEnabled = computed(() => getFeature(PlanFeatureTypes.FEATURE_PERSONAL_VIEWS))
|
||||
@@ -232,10 +238,12 @@ const canLockView = computed(() => isUIAllowed('fieldAdd'))
|
||||
|
||||
// Personal views are an EE-only concept — CE has only Collaborative
|
||||
// and the legacy Locked lock_types.
|
||||
// Locked views cannot be created on a sandbox master — make the change in the sandbox instead.
|
||||
// Personal views cannot be created on a sandbox — they belong to the master base.
|
||||
const lockTypeOptions = computed(() => {
|
||||
const options: Array<{ value: ViewLockType; disabled?: boolean }> = [{ value: ViewLockType.Collaborative }]
|
||||
if (isEeUI) options.push({ value: ViewLockType.Personal })
|
||||
if (canLockView.value) options.push({ value: ViewLockType.Locked })
|
||||
if (isEeUI) options.push({ value: ViewLockType.Personal, disabled: isSandbox.value })
|
||||
if (canLockView.value) options.push({ value: ViewLockType.Locked, disabled: isSandboxProduction.value })
|
||||
return options
|
||||
})
|
||||
|
||||
@@ -1017,42 +1025,56 @@ watch(activeBaseId, () => {
|
||||
<template v-for="option in lockTypeOptions" :key="option.value">
|
||||
<!-- Personal is payment-gated: on unlicensed on-prem / non-Plus cloud,
|
||||
the radio shows an upgrade badge and clicks open the upgrade
|
||||
modal instead of setting lock_type. Mirrors the View mode
|
||||
submenu pattern in ViewActionMenu.vue. -->
|
||||
<PaymentUpgradeBadgeProvider
|
||||
modal instead of setting lock_type. On a sandbox base, personal
|
||||
views are disabled — they must be created on the master base. -->
|
||||
<NcTooltip
|
||||
v-if="option.value === ViewLockType.Personal && isEeUI && showEEFeatures"
|
||||
:feature="PlanFeatureTypes.FEATURE_PERSONAL_VIEWS"
|
||||
:disabled="!option.disabled"
|
||||
:title="$t('tooltip.personalViewDisabledOnSandbox')"
|
||||
>
|
||||
<template #default="{ click }">
|
||||
<a-radio
|
||||
:value="option.value"
|
||||
:data-testid="`nc-create-view-lock-type-${option.value}`"
|
||||
@click.capture="
|
||||
(e) => {
|
||||
if (!isPersonalViewFeatureEnabled) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
click(PlanFeatureTypes.FEATURE_PERSONAL_VIEWS)
|
||||
<PaymentUpgradeBadgeProvider :feature="PlanFeatureTypes.FEATURE_PERSONAL_VIEWS">
|
||||
<template #default="{ click }">
|
||||
<a-radio
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
:data-testid="`nc-create-view-lock-type-${option.value}`"
|
||||
@click.capture="
|
||||
(e) => {
|
||||
if (!isPersonalViewFeatureEnabled) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
click(PlanFeatureTypes.FEATURE_PERSONAL_VIEWS)
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap text-[13px]">
|
||||
<component :is="viewLockIcons[option.value].icon" class="w-3.5 h-3.5 flex-none" />
|
||||
{{ $t(viewLockIcons[option.value].title) }}
|
||||
<!-- show-as-lock renders a compact lock icon when gated
|
||||
and auto-hides when the feature is enabled -->
|
||||
<PaymentUpgradeBadge :feature="PlanFeatureTypes.FEATURE_PERSONAL_VIEWS" show-as-lock />
|
||||
</span>
|
||||
</a-radio>
|
||||
</template>
|
||||
</PaymentUpgradeBadgeProvider>
|
||||
<a-radio v-else :value="option.value" :data-testid="`nc-create-view-lock-type-${option.value}`">
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap text-[13px]">
|
||||
<component :is="viewLockIcons[option.value].icon" class="w-3.5 h-3.5 flex-none" />
|
||||
{{ $t(viewLockIcons[option.value].title) }}
|
||||
</span>
|
||||
</a-radio>
|
||||
"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap text-[13px]">
|
||||
<component :is="viewLockIcons[option.value].icon" class="w-3.5 h-3.5 flex-none" />
|
||||
{{ $t(viewLockIcons[option.value].title) }}
|
||||
<!-- show-as-lock renders a compact lock icon when gated
|
||||
and auto-hides when the feature is enabled -->
|
||||
<PaymentUpgradeBadge :feature="PlanFeatureTypes.FEATURE_PERSONAL_VIEWS" show-as-lock />
|
||||
</span>
|
||||
</a-radio>
|
||||
</template>
|
||||
</PaymentUpgradeBadgeProvider>
|
||||
</NcTooltip>
|
||||
<NcTooltip
|
||||
v-else
|
||||
:disabled="!option.disabled || option.value !== ViewLockType.Locked"
|
||||
:title="$t('tooltip.lockedViewDisabledOnSandboxMaster')"
|
||||
>
|
||||
<a-radio
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
:data-testid="`nc-create-view-lock-type-${option.value}`"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap text-[13px]">
|
||||
<component :is="viewLockIcons[option.value].icon" class="w-3.5 h-3.5 flex-none" />
|
||||
{{ $t(viewLockIcons[option.value].title) }}
|
||||
</span>
|
||||
</a-radio>
|
||||
</NcTooltip>
|
||||
</template>
|
||||
</a-radio-group>
|
||||
<div class="text-[12px] text-nc-content-gray-subtle2 leading-[16px]">
|
||||
|
||||
@@ -179,6 +179,8 @@ watch(
|
||||
projectPageTab.value = 'workflows'
|
||||
} else if (newVal === 'mcp') {
|
||||
projectPageTab.value = 'mcp'
|
||||
} else if (newVal === 'variables') {
|
||||
projectPageTab.value = 'variables'
|
||||
} else if (newVal === 'snapshots' && isEeUI) {
|
||||
projectPageTab.value = 'snapshots'
|
||||
} else if (newVal === 'record-trash' && isEeUI) {
|
||||
@@ -208,6 +210,7 @@ const settingsPageTitle = computed(() => {
|
||||
'permissions': t('labels.dataPermissions'),
|
||||
'docs-permissions': t('labels.docsPermissions'),
|
||||
'mcp': t('title.mcpServer'),
|
||||
'variables': t('title.baseVariables'),
|
||||
'syncs': t('labels.manageSyncs'),
|
||||
'snapshots': t('labels.manageSnapshots'),
|
||||
'record-trash': t('trash.settings'),
|
||||
@@ -232,7 +235,10 @@ watch(projectPageTab, () => {
|
||||
const wsId = route.value.params.typeOrId
|
||||
|
||||
const baseId = route.value.params.baseId
|
||||
navigateTo(`/${wsId}/${baseId}/settings/${slug}`)
|
||||
navigateTo({
|
||||
path: `/${wsId}/${baseId}/settings/${slug}`,
|
||||
query: route.value.query,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -383,6 +389,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showEmptySkeleton && !isMobileMode" class="flex items-center gap-2">
|
||||
<SmartsheetTopbarVariableSetupWarning />
|
||||
<SmartsheetTopbarManagedAppStatus />
|
||||
<SmartsheetTopbarSandboxStatus />
|
||||
<LazySmartsheetTopbarHistory />
|
||||
@@ -553,6 +560,17 @@ watch(
|
||||
<DashboardSettingsBaseMCP />
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane v-if="isEeUI && base.id && !isMobileMode" key="variables">
|
||||
<template #tab>
|
||||
<div class="tab-title" data-testid="proj-view-tab__variables">
|
||||
<GeneralIcon icon="ncSettings" />
|
||||
<div>{{ $t('title.baseVariables') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-6 h-full max-h-full overflow-auto nc-scrollbar-thin">
|
||||
<DashboardSettingsBaseVariables />
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane
|
||||
v-if="isEeUI && showEEFeatures && isUIAllowed('baseTrashSettingsList') && base.id && !isMobileMode"
|
||||
key="record-trash"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NcSpanHidden />
|
||||
</template>
|
||||
@@ -75,6 +75,9 @@ const topbarBreadcrumbItemWidth = computed(() => {
|
||||
<div class="flex items-center justify-end gap-2 flex-1">
|
||||
<GeneralApiLoader v-if="!isMobileMode && !activeScriptId && !activeDashboardId" />
|
||||
|
||||
<!-- Variable Setup Warning -->
|
||||
<SmartsheetTopbarVariableSetupWarning v-if="!isSharedBase && !isMobileMode" />
|
||||
|
||||
<!-- Managed App Status -->
|
||||
<LazySmartsheetTopbarManagedAppStatus v-if="!isSharedBase && !isMobileMode" />
|
||||
|
||||
|
||||
@@ -332,6 +332,7 @@ const onDuplicateRow = () => {
|
||||
isUnsavedDuplicatedRecordExist.value = true
|
||||
const oldRow = { ..._row.value.row }
|
||||
delete oldRow.ncRecordId
|
||||
delete oldRow.ncRecordHash
|
||||
const newRow = Object.assign(
|
||||
{},
|
||||
{
|
||||
|
||||
@@ -281,9 +281,16 @@ const modifyViewDisabledReason = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
// Collaborative views cannot be deleted on a sandbox master base — backend guard mirrors this.
|
||||
// Personal views (owned_by set) can still be deleted by their owner.
|
||||
const isSandboxProductionCollaborativeDelete = computed(() => {
|
||||
return !!base.value?.is_sandbox_production && !view.value?.owned_by
|
||||
})
|
||||
|
||||
// Tooltip shown when Delete is disabled.
|
||||
const deleteDisabledReason = computed(() => {
|
||||
if (isLockedView.value) return t('msg.info.disabledAsViewLocked')
|
||||
if (isSandboxProductionCollaborativeDelete.value) return t('msg.info.disabledAsSandboxMasterCollabDelete')
|
||||
if (blockViewOperations.value && !isPersonalView.value) return t('msg.info.cantDeleteLastGridView')
|
||||
if (isPersonalView.value && !isPersonalViewOwner.value) return t('tooltip.onlyViewOwnerCanDeletePersonalView')
|
||||
return ''
|
||||
@@ -293,6 +300,7 @@ const deleteDisabledReason = computed(() => {
|
||||
// (which is enforced for everyone, including creators+).
|
||||
const isDeleteDisabled = computed(() => {
|
||||
if (!canDeleteView.value) return true
|
||||
if (isSandboxProductionCollaborativeDelete.value) return true
|
||||
if (blockViewOperations.value && !isPersonalView.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<span />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<span />
|
||||
</template>
|
||||
@@ -28,6 +28,12 @@ const installing = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref<string | undefined>(undefined)
|
||||
|
||||
// Variable setup state
|
||||
const isVariableSetupVisible = ref(false)
|
||||
const setupVariables = ref<any[]>([])
|
||||
const setupBaseId = ref('')
|
||||
const setupBaseTitle = ref('')
|
||||
|
||||
const categories = computed(() => {
|
||||
const cats = new Set<string>()
|
||||
managedApps.value.forEach((ma) => {
|
||||
@@ -92,7 +98,7 @@ const loadManagedApps = async () => {
|
||||
const installManagedApp = async (managedApp: ManagedAppType) => {
|
||||
installing.value = managedApp.id
|
||||
try {
|
||||
await $api.internal.postOperation(
|
||||
const response = (await $api.internal.postOperation(
|
||||
props.workspaceId,
|
||||
NO_SCOPE,
|
||||
{
|
||||
@@ -102,11 +108,19 @@ const installManagedApp = async (managedApp: ManagedAppType) => {
|
||||
managedAppId: managedApp.id,
|
||||
target_workspace_id: props.workspaceId,
|
||||
},
|
||||
)
|
||||
)) as any
|
||||
|
||||
message.success(t('msg.success.baseInstalled'))
|
||||
emit('installed', managedApp)
|
||||
visible.value = false
|
||||
|
||||
// If setup is required, show variable configuration modal
|
||||
if (response?.setupRequired && response?.setupVariables?.length) {
|
||||
setupVariables.value = response.setupVariables
|
||||
setupBaseId.value = response.installedBaseId
|
||||
setupBaseTitle.value = managedApp.title
|
||||
isVariableSetupVisible.value = true
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(await extractSdkResponseErrorMsg(e))
|
||||
} finally {
|
||||
@@ -297,6 +311,16 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DlgManagedAppVariableSetup
|
||||
v-if="isVariableSetupVisible"
|
||||
v-model:visible="isVariableSetupVisible"
|
||||
:variables="setupVariables"
|
||||
:base-id="setupBaseId"
|
||||
:base-title="setupBaseTitle"
|
||||
:workspace-id="props.workspaceId"
|
||||
@configured="isVariableSetupVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BaseVariableReqType, BaseVariableType } from 'nocodb-sdk'
|
||||
|
||||
export const useBaseVariables = createSharedComposable(() => {
|
||||
const variables = ref<BaseVariableType[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const unconfiguredRequiredVars = computed<BaseVariableType[]>(() => [])
|
||||
const hasUnconfiguredVars = computed(() => false)
|
||||
|
||||
const listVariables = async () => {}
|
||||
const createVariable = async (_body: BaseVariableReqType) => {}
|
||||
const updateVariable = async (_variableId: string, _body: Partial<BaseVariableReqType>) => {}
|
||||
const deleteVariable = async (_variableId: string) => {}
|
||||
const revertToDefault = async (_variableId: string) => {}
|
||||
const bulkUpdateValues = async (_baseId: string, _updates: { id: string; value: string }[], _workspaceId?: string) => {}
|
||||
|
||||
return {
|
||||
variables,
|
||||
isLoading,
|
||||
unconfiguredRequiredVars,
|
||||
hasUnconfiguredVars,
|
||||
listVariables,
|
||||
createVariable,
|
||||
updateVariable,
|
||||
deleteVariable,
|
||||
revertToDefault,
|
||||
bulkUpdateValues,
|
||||
}
|
||||
})
|
||||
@@ -2,14 +2,6 @@ import rfdc from 'rfdc'
|
||||
|
||||
const deepClone = rfdc()
|
||||
const FEATURES = [
|
||||
{
|
||||
id: 'sandbox',
|
||||
title: 'Sandbox',
|
||||
description: 'Allow users to create sandbox environments for testing schema changes before merging.',
|
||||
enabled: false,
|
||||
isEngineering: true,
|
||||
isAdvanced: true,
|
||||
},
|
||||
{
|
||||
id: 'managed_apps',
|
||||
title: 'Managed Apps',
|
||||
|
||||
@@ -260,6 +260,10 @@ export const useEeConfig = createSharedComposable(() => {
|
||||
|
||||
const showUpgradeToUseViewSections = (..._args: any[]) => {}
|
||||
|
||||
const blockBaseVariables = computed(() => true)
|
||||
|
||||
const showUpgradeToUseBaseVariables = (..._args: any[]) => {}
|
||||
|
||||
const showSandboxPlanLimitExceededModal = (..._args: any[]) => {}
|
||||
const showUpgradeToUseListView = (..._args: any[]) => {}
|
||||
|
||||
@@ -439,6 +443,8 @@ export const useEeConfig = createSharedComposable(() => {
|
||||
showUpgradeToUseFormScheduling,
|
||||
blockViewSections,
|
||||
showUpgradeToUseViewSections,
|
||||
blockBaseVariables,
|
||||
showUpgradeToUseBaseVariables,
|
||||
blockListView,
|
||||
showUpgradeToUseListView,
|
||||
blockMapView,
|
||||
|
||||
@@ -14,8 +14,17 @@ export const useMetas = createSharedComposable(() => {
|
||||
|
||||
const { baseTables } = storeToRefs(useTablesStore())
|
||||
|
||||
// keep a temporary state of deleted tables to avoid get api calls
|
||||
const deletedTableIds = new Set<string>()
|
||||
// keep a temporary state of deleted tables per base to avoid get api calls
|
||||
const deletedTableIdsByBase = new Map<string, Set<string>>()
|
||||
|
||||
const getDeletedTableIds = (baseId: string): Set<string> => {
|
||||
let set = deletedTableIdsByBase.get(baseId)
|
||||
if (!set) {
|
||||
set = new Set<string>()
|
||||
deletedTableIdsByBase.set(baseId, set)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Helper function to create composite key: baseId:tableIdOrTitle
|
||||
const getMetaKey = (baseId: string, tableIdOrTitle: string) => `${baseId}:${tableIdOrTitle}`
|
||||
@@ -89,9 +98,10 @@ export const useMetas = createSharedComposable(() => {
|
||||
|
||||
// if marked as deleted, verify it's actually still gone
|
||||
// (e.g., table restored from trash re-appears in baseTables)
|
||||
if (deletedTableIds.has(tableIdOrTitle)) {
|
||||
const deletedSet = deletedTableIdsByBase.get(baseId)
|
||||
if (deletedSet?.has(tableIdOrTitle)) {
|
||||
if (tables.some((t) => t.id === tableIdOrTitle)) {
|
||||
deletedTableIds.delete(tableIdOrTitle)
|
||||
deletedSet.delete(tableIdOrTitle)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -179,7 +189,17 @@ export const useMetas = createSharedComposable(() => {
|
||||
|
||||
const clearAllMeta = () => {
|
||||
metas.value = {}
|
||||
deletedTableIds.clear()
|
||||
deletedTableIdsByBase.clear()
|
||||
}
|
||||
|
||||
/** Clear cached meta for a single base and reset deleted-table tracking. */
|
||||
const clearBaseMeta = (baseId: string) => {
|
||||
deletedTableIdsByBase.delete(baseId)
|
||||
for (const key of Object.keys(metas.value)) {
|
||||
if (key.startsWith(`${baseId}:`)) {
|
||||
delete metas.value[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeMeta = (baseId: string, idOrTitle: string, deleted = false) => {
|
||||
@@ -187,24 +207,25 @@ export const useMetas = createSharedComposable(() => {
|
||||
const meta = metas.value[metaKey]
|
||||
|
||||
if (meta) {
|
||||
if (deleted) deletedTableIds.add(meta.id)
|
||||
if (deleted) getDeletedTableIds(baseId).add(meta.id)
|
||||
delete metas.value[getMetaKey(baseId, meta.id)]
|
||||
delete metas.value[getMetaKey(baseId, meta.title)]
|
||||
}
|
||||
}
|
||||
|
||||
const clearDeletedTableId = (tableId: string) => {
|
||||
deletedTableIds.delete(tableId)
|
||||
const clearDeletedTableId = (baseId: string, tableId: string) => {
|
||||
deletedTableIdsByBase.get(baseId)?.delete(tableId)
|
||||
}
|
||||
|
||||
// return partial metadata for related table of a meta service
|
||||
const getPartialMeta = async (baseId: string, linkColumnId: string, tableIdOrTitle: string): Promise<TableType | null> => {
|
||||
if (!tableIdOrTitle || !linkColumnId) return null
|
||||
|
||||
if (deletedTableIds.has(tableIdOrTitle)) {
|
||||
const deletedSet = deletedTableIdsByBase.get(baseId)
|
||||
if (deletedSet?.has(tableIdOrTitle)) {
|
||||
const tables = baseTables.value.get(baseId) ?? []
|
||||
if (tables.some((t) => t.id === tableIdOrTitle)) {
|
||||
deletedTableIds.delete(tableIdOrTitle)
|
||||
deletedSet.delete(tableIdOrTitle)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -245,6 +266,7 @@ export const useMetas = createSharedComposable(() => {
|
||||
return {
|
||||
getMeta,
|
||||
clearAllMeta,
|
||||
clearBaseMeta,
|
||||
metas,
|
||||
metasWithIdAsKey,
|
||||
removeMeta,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const useSandboxChangelog = createSharedComposable(() => {
|
||||
const isDrawerOpen = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const data = ref<{ changelog: any[]; users: Record<string, any> }>({ changelog: [], users: {} })
|
||||
const mergeStatus = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const mergeError = ref('')
|
||||
const DRAWER_WIDTH = 480
|
||||
|
||||
const openDrawer = () => {}
|
||||
const closeDrawer = () => {}
|
||||
const mergeFromChangelog = async () => {}
|
||||
const loadChangelog = async () => {}
|
||||
|
||||
return {
|
||||
DRAWER_WIDTH,
|
||||
isDrawerOpen,
|
||||
isLoading,
|
||||
data,
|
||||
mergeStatus,
|
||||
mergeError,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
mergeFromChangelog,
|
||||
loadChangelog,
|
||||
}
|
||||
})
|
||||
@@ -236,6 +236,8 @@
|
||||
"upgradeToUseViewSections": "Upgrade to use view sections",
|
||||
"upgradeToUseViewSectionsSubtitle": "Upgrade to the {plan} plan to organize views into sections.",
|
||||
"upgradeToAccessViewSectionsSubtitle": "Upgrade to the {plan} plan to organize views into sections.",
|
||||
"upgradeToUseBaseVariables": "Upgrade to use base variables",
|
||||
"upgradeToUseBaseVariablesSubtitle": "Upgrade to the {plan} plan to define reusable variables scoped to a base.",
|
||||
"upgradeToUseSync": "Upgrade to use sync",
|
||||
"upgradeToUseSyncSubtitle": "Upgrade to the {plan} plan to sync data with internal or external sources.",
|
||||
"upgradeToEnableUnique": "Upgrade to enable unique",
|
||||
@@ -1067,6 +1069,7 @@
|
||||
"setPassword": "Set Password",
|
||||
"externalIntegrations": "External Integrations",
|
||||
"mcpServer": "MCP Server",
|
||||
"baseVariables": "Variables",
|
||||
"oauthClients": "OAuth Clients",
|
||||
"selectAttachmentField": "Select Attachment Field",
|
||||
"accessRestricted": "Access Restricted",
|
||||
@@ -1610,6 +1613,30 @@
|
||||
"never": "Never",
|
||||
"expiresAt": "Expires At",
|
||||
"newMCPEndpoint": "New MCP Endpoint",
|
||||
"newVariable": "New Variable",
|
||||
"variableKey": "Key",
|
||||
"variableValue": "Value",
|
||||
"variableMode": "Mode",
|
||||
"variableAccess": "Access",
|
||||
"variableInheritance": "Inheritance",
|
||||
"variableType": "Type",
|
||||
"variableDescription": "Description",
|
||||
"variableSensitive": "Sensitive",
|
||||
"configureVariables": "Configure Variables",
|
||||
"fixedByMaster": "Fixed by master — cannot be changed",
|
||||
"fixedVariableReadonly": "This is a fixed variable set by the master base. Its value cannot be changed in the sandbox.",
|
||||
"deleteVariableConfirmation": "This variable will be permanently deleted. This action cannot be undone.",
|
||||
"usingDefault": "Using default",
|
||||
"revertToDefault": "Revert to default",
|
||||
"variablesSyncedFromMaster": "Variables inherited from master. Changes here won't affect the master base.",
|
||||
"requiredVariablesMissing": "required variables need configuration",
|
||||
"setupRequired": "Setup Required",
|
||||
"inheritanceFixed": "Fixed",
|
||||
"inheritanceFixedDesc": "Inherited as-is, cannot be changed",
|
||||
"inheritanceEditable": "Editable",
|
||||
"inheritanceEditableDesc": "Inherited with default, can be overridden",
|
||||
"inheritanceRequired": "Required",
|
||||
"inheritanceRequiredDesc": "Must be set by the child",
|
||||
"mcpSubText": "The NocoDB MCP (Model Context Protocol) can be added to any client that supports MCP. It's a unique endpoint that allows LLMs to directly perform actions (based on your role) within your base on your behalf.",
|
||||
"modelContextProtocol": "Model Context Protocol",
|
||||
"mcp": "MCP",
|
||||
@@ -1702,8 +1729,18 @@
|
||||
"revertAllChangesToMatchMaster": "Revert all changes to match master",
|
||||
"publishSandboxChanges": "Publish Sandbox Changes",
|
||||
"sandboxChangesPublished": "Sandbox Changes Published",
|
||||
"sandboxChanges": "Sandbox changes",
|
||||
"viewSandboxChanges": "View changes",
|
||||
"noSandboxChanges": "No changes yet",
|
||||
"sandboxNoChangesHint": "Schema changes made in this sandbox will appear here.",
|
||||
"sandboxChangesApplyDescription": "The following will be applied to production. We'll take a snapshot before applying changes.",
|
||||
"applyNChanges": "Apply {count} change | Apply {count} changes",
|
||||
"applyChangesToMaster": "Apply to master",
|
||||
"yesterday": "Yesterday",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
"failedToPublishChanges": "Failed to Publish Changes",
|
||||
"mergeSchemaChangesWarning": "This will merge all schema changes from this sandbox into the master base.",
|
||||
"mergeSchemaChangesWarning": "This will apply all structure changes from this sandbox to the master base. Row data and variable values will not be transferred.",
|
||||
"sandboxChangesPublishedSuccess": "Sandbox changes have been published successfully.",
|
||||
"viewChangesOnMaster": "You can now go to the master base to view the changes.",
|
||||
"publishChangesAction": "Publish Changes",
|
||||
@@ -1715,7 +1752,6 @@
|
||||
"enterSandboxTitle": "Enter sandbox title",
|
||||
"sandboxDescriptionPlaceholder": "What are you working on in this sandbox?",
|
||||
"creatingSandbox": "Creating Sandbox...",
|
||||
"goToSandbox": "Go to Sandbox",
|
||||
"sandboxCreatedSuccess": "Sandbox \"{title}\" has been created successfully.",
|
||||
"sandboxCreatedInstructions": "You can now make changes to this sandbox. When ready, publish your changes back to the master base.",
|
||||
"allSandboxes": "All Sandboxes",
|
||||
@@ -1723,9 +1759,18 @@
|
||||
"noSandboxesFound": "No sandboxes found. Create one to get started.",
|
||||
"noDescriptionProvided": "No description provided.",
|
||||
"deleteSandbox": "Delete Sandbox",
|
||||
"deleteSandboxWarning": "This will permanently delete this sandbox and all its data. This action cannot be undone.",
|
||||
"sandboxDeleted": "Sandbox deleted successfully",
|
||||
"permanentlyRemoveSandbox": "Permanently remove this sandbox",
|
||||
"sandboxChangesReverted": "Sandbox changes reverted successfully",
|
||||
"discardSandboxWarning": "This will permanently revert all schema changes in this sandbox to match the master base. This action cannot be undone.",
|
||||
"createNewSandbox": "Create new sandbox",
|
||||
"viewAllSandboxes": "View all sandboxes",
|
||||
"master": "Master",
|
||||
"schemaLocked": "Schema Locked",
|
||||
"schemaLockedActive": "Schema Editing Locked",
|
||||
"schemaLockedDescription": "An active sandbox exists. Schema changes can only be made in the sandbox.",
|
||||
"goToSandbox": "Go to Sandbox",
|
||||
"openSandboxToEditSchema": "Open sandbox to make schema changes",
|
||||
"justNow": "Just now",
|
||||
"goLive": "Go live",
|
||||
"pleaseProvideVersion": "Please provide a version",
|
||||
@@ -2103,6 +2148,8 @@
|
||||
"setupTwoFactorAuth": "Set Up 2FA",
|
||||
"requireTwoFactor": "Require two-factor authentication",
|
||||
"requireTwoFactorDescription": "All workspace members will be required to set up two-factor authentication before accessing this workspace.",
|
||||
"requireTwoFactorSsoNote": "Note: Members who sign in via SSO are exempt from this requirement, as their authentication is managed by the identity provider.",
|
||||
"twoFactorSsoNotAvailable": "Two-factor authentication is not available for accounts that sign in via SSO.",
|
||||
"controlAppearance": "Control your Appearance.",
|
||||
"accountEmailID": "Account Email ID",
|
||||
"backToWorkspace": "Back to Workspace",
|
||||
@@ -2841,10 +2888,18 @@
|
||||
"goBackHome": "Go back home"
|
||||
},
|
||||
"tooltip": {
|
||||
"showExtensions": "Show extensions",
|
||||
"hideExtensions": "Hide extensions",
|
||||
"sandboxRequiresV3": "Sandbox requires a V3 base. Please migrate this base to V3 first.",
|
||||
"sandboxLimitReached": "This base already has a sandbox. Only one sandbox per base is allowed.",
|
||||
"lockedViewDisabledOnSandboxMaster": "Locked views can only be created in the sandbox",
|
||||
"personalViewDisabledOnSandbox": "Personal views can only be created on the master base",
|
||||
"noSchemaChanges": "No schema changes to publish",
|
||||
"scopeReadOnly": "View records, tables, fields, views, and comments. No modifications allowed.",
|
||||
"scopeReadWrite": "Create, read, update, and delete records and comments. Other resources are read-only.",
|
||||
"scopeFullAccess": "Full read and write access to all resources including tables, fields, views, webhooks, and users.",
|
||||
"scopeCustom": "Pick individual permissions per category for fine-grained control.",
|
||||
"variableInheritance": "Controls how this variable behaves in managed apps and sandboxes derived from this base",
|
||||
"inheritedMembersInfo": "These members belong to a parent team and inherit access to resources assigned to this team.",
|
||||
"teamScopeThisOnly": "This team only — click to include sub-teams",
|
||||
"teamScopeIncludesSubTeams": "Includes sub-teams — click to match this team only",
|
||||
@@ -2994,6 +3049,9 @@
|
||||
"placeholder": {
|
||||
"enterVerificationCode": "Enter 6-digit code",
|
||||
"enterBackupCode": "Enter backup code (e.g., xxxx-xxxx)",
|
||||
"variableKey": "e.g. WEBHOOK_URL",
|
||||
"variableValue": "Enter value",
|
||||
"variableDescription": "Describe this variable",
|
||||
"askAnything": "Ask or build anything",
|
||||
"somethingElse": "Something else",
|
||||
"docEditor": "Start writing or type / for commands...",
|
||||
@@ -3134,6 +3192,9 @@
|
||||
"publishingWillMakeAvailable": "Publishing version {version} will make it available in the App Store and automatically update all installations.",
|
||||
"createDraftDescription": "Create a new draft version to work on updates. Current published version {version} will remain unchanged.",
|
||||
"createVersionToMakeChanges": "Create {version} to make changes",
|
||||
"allInstallationsUpdated": "All installations updated successfully",
|
||||
"installationUpdatePartial": "{success} installations updated, {failed} failed",
|
||||
"installationUpdateFailed": "Failed to update installations",
|
||||
"trackingInstallationsFor": "Tracking installations for",
|
||||
"currentlyOnVersion": "Currently on {version}",
|
||||
"ssoTokenTooltip": "Generated after SSO login and can be used for accessing APIs under SSO-enabled workspace",
|
||||
@@ -3320,10 +3381,15 @@
|
||||
},
|
||||
"reachedUploadLimit": "Reached upload limit",
|
||||
"youCanOnlyUploadMaxLimitFilesAtATime": "You can only upload {limit} {type} file at a time",
|
||||
"youCanOnlyUploadMaxLimitFilesAtATimePlural": "You can only upload {limit} {type} files at a time"
|
||||
"youCanOnlyUploadMaxLimitFilesAtATimePlural": "You can only upload {limit} {type} files at a time",
|
||||
"variableKeyRequired": "Variable key is required",
|
||||
"variableKeyFormat": "Key must be uppercase letters, numbers and underscores (e.g. WEBHOOK_URL)"
|
||||
},
|
||||
"info": {
|
||||
"trashEmpty": "Trash is empty",
|
||||
"variableKeyImmutable": "Key cannot be changed after creation",
|
||||
"variableSetupHint": "Set values for the variables required by this base. Required fields must be configured for full functionality.",
|
||||
"setupRequired": "This app requires configuration before use",
|
||||
"contactAdminForAccess": "You haven't been added to any workspace yet. Contact your administrator to get access.",
|
||||
"licenseRequiresPostgres": "Enterprise licensing requires PostgreSQL. Please migrate to PostgreSQL to activate your license key.",
|
||||
"licensesUpToDate": "All licenses are up to date",
|
||||
@@ -3370,6 +3436,7 @@
|
||||
"noOIDC": "There are no configured OpenID authentications.",
|
||||
"disabledAsViewLocked": "Disabled as View is locked",
|
||||
"noFiltersApplied": "No filters applied",
|
||||
"disabledAsSandboxMasterCollabDelete": "Collaborative views can only be deleted from the sandbox",
|
||||
"basesMigrated": "Bases are migrated. Please try again.",
|
||||
"pasteNotSupported": "Paste operation is not supported on the active cell",
|
||||
"pasteNotSupportedInSyncedCells": "Paste operation is not supported in synced fields",
|
||||
@@ -3605,6 +3672,8 @@
|
||||
"workspaceTeamRoleUpdateNotice": "Changing a team’s workspace role will also update its access to bases where it doesn’t have an explicit role."
|
||||
},
|
||||
"error": {
|
||||
"variableKeyRequired": "Variable key is required",
|
||||
"variableKeyFormat": "Key must be uppercase letters, numbers and underscores (e.g. WEBHOOK_URL)",
|
||||
"latitudeRange": "Latitude must be between -90 and 90",
|
||||
"longitudeRange": "Longitude must be between -180 and 180",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
@@ -3816,6 +3885,10 @@
|
||||
"mcpTokenDeleted": "MCP Token Deleted",
|
||||
"mcpTokenUpdated": "MCP Token Updated",
|
||||
"mcpTokenCreated": "MCP Token Created",
|
||||
"variableCreated": "Variable created",
|
||||
"variableUpdated": "Variable updated",
|
||||
"variableDeleted": "Variable deleted",
|
||||
"variablesConfigured": "Variables configured",
|
||||
"licenseKeyUpdated": "License Key Updated",
|
||||
"rlsPolicySaved": "Record-level security policy saved",
|
||||
"teamMoved": "Team moved successfully",
|
||||
|
||||
@@ -20,6 +20,7 @@ export const baseSettingsTabToSlug: Record<string, string> = {
|
||||
'overview': 'overview',
|
||||
'mcp': 'mcp',
|
||||
'record-trash': 'record-trash',
|
||||
'variables': 'variables',
|
||||
'snapshots': 'snapshots',
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
BaseVariableInheritance,
|
||||
BaseVariableValueType,
|
||||
} from '~/lib/globals';
|
||||
|
||||
export interface BaseVariableType {
|
||||
id?: string;
|
||||
base_id?: string;
|
||||
fk_workspace_id?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
inheritance?: BaseVariableInheritance;
|
||||
type?: BaseVariableValueType;
|
||||
order?: number;
|
||||
is_overridden?: boolean;
|
||||
is_inherited?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseVariableReqType {
|
||||
key: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
inheritance: BaseVariableInheritance;
|
||||
type?: BaseVariableValueType;
|
||||
}
|
||||
@@ -364,6 +364,19 @@ export enum AppEvents {
|
||||
|
||||
RESOURCE_RESTORE = 'resource.restore',
|
||||
RESOURCE_PERMANENT_DELETE = 'resource.permanent_delete',
|
||||
|
||||
BASE_VARIABLE_CREATE = 'baseVariable.create',
|
||||
BASE_VARIABLE_UPDATE = 'baseVariable.update',
|
||||
BASE_VARIABLE_DELETE = 'baseVariable.delete',
|
||||
|
||||
MANAGED_APP_CREATE = 'managedApp.create',
|
||||
MANAGED_APP_UPDATE = 'managedApp.update',
|
||||
MANAGED_APP_DELETE = 'managedApp.delete',
|
||||
MANAGED_APP_PUBLISH = 'managedApp.publish',
|
||||
MANAGED_APP_INSTALL = 'managedApp.install',
|
||||
MANAGED_APP_UPDATE_START = 'managedApp.update.start',
|
||||
MANAGED_APP_UPDATE_COMPLETE = 'managedApp.update.complete',
|
||||
MANAGED_APP_UPDATE_FAIL = 'managedApp.update.fail',
|
||||
}
|
||||
|
||||
export enum ClickhouseTables {
|
||||
@@ -719,5 +732,6 @@ export enum MapProvider {
|
||||
STADIAMAP_APIKEY = 'stadiamap_apikey',
|
||||
}
|
||||
|
||||
|
||||
/** Default org ID for on-prem deployments */
|
||||
export const NC_DEFAULT_ORG_ID = 'org_default';
|
||||
@@ -1049,7 +1049,7 @@ export class NcErrorBase {
|
||||
{
|
||||
params: limit.toString(),
|
||||
...args,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1072,4 +1072,21 @@ export class NcErrorBase {
|
||||
...args,
|
||||
});
|
||||
}
|
||||
|
||||
sandboxBlocked(message?: string, args?: NcErrorArgs): never {
|
||||
throw this.errorCodex.generateError(NcErrorType.ERR_SANDBOX_BLOCKED, {
|
||||
params: message || '',
|
||||
...args,
|
||||
});
|
||||
}
|
||||
|
||||
sandboxProductionBlocked(message?: string, args?: NcErrorArgs): never {
|
||||
throw this.errorCodex.generateError(
|
||||
NcErrorType.ERR_SANDBOX_PRODUCTION_BLOCKED,
|
||||
{
|
||||
params: message || '',
|
||||
...args,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -483,4 +483,15 @@ export const presetErrorCodexMap: Partial<
|
||||
message || 'Too many requests. Please try again later.',
|
||||
code: 429,
|
||||
},
|
||||
[NcErrorType.ERR_SANDBOX_BLOCKED]: {
|
||||
message: (message: string) =>
|
||||
message || 'This operation is not allowed in a sandbox base.',
|
||||
code: 403,
|
||||
},
|
||||
[NcErrorType.ERR_SANDBOX_PRODUCTION_BLOCKED]: {
|
||||
message: (message: string) =>
|
||||
message ||
|
||||
'This operation is not allowed while a sandbox is active. Make the change in the sandbox instead.',
|
||||
code: 403,
|
||||
},
|
||||
};
|
||||
@@ -291,6 +291,10 @@ export enum NcErrorType {
|
||||
// System configuration errors
|
||||
ERR_SYSTEM_MISCONFIGURED = 'ERR_SYSTEM_MISCONFIGURED',
|
||||
ERR_TOO_MANY_REQUESTS = 'ERR_TOO_MANY_REQUESTS',
|
||||
|
||||
// Sandbox errors
|
||||
ERR_SANDBOX_BLOCKED = 'ERR_SANDBOX_BLOCKED',
|
||||
ERR_SANDBOX_PRODUCTION_BLOCKED = 'ERR_SANDBOX_PRODUCTION_BLOCKED',
|
||||
}
|
||||
|
||||
export enum ROW_COLORING_MODE {
|
||||
@@ -438,3 +442,14 @@ export enum DeploymentType {
|
||||
INSTALL = 'install',
|
||||
UPDATE = 'update',
|
||||
}
|
||||
|
||||
export enum BaseVariableInheritance {
|
||||
FIXED = 'fixed',
|
||||
EDITABLE = 'editable',
|
||||
REQUIRED = 'required',
|
||||
}
|
||||
|
||||
export enum BaseVariableValueType {
|
||||
TEXT = 'text',
|
||||
SECRET = 'secret',
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export * from '~/lib/realtime';
|
||||
export * from '~/lib/scripts';
|
||||
export * from '~/lib/oauth';
|
||||
export * from '~/lib/unifiedMeta';
|
||||
export * from '~/lib/base-variable';
|
||||
export * from '~/lib/managed-app';
|
||||
export * from '~/lib/types';
|
||||
export * from '~/lib/helpers/metaHelpers';
|
||||
export * from '~/lib/circularReplacer';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
DeploymentStatus,
|
||||
DeploymentType,
|
||||
ManagedAppVersionStatus,
|
||||
ManagedAppVisibility,
|
||||
} from '~/lib/globals';
|
||||
|
||||
export interface ManagedAppType {
|
||||
id?: string;
|
||||
base_id?: string;
|
||||
fk_workspace_id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
created_by?: string;
|
||||
visibility?: ManagedAppVisibility;
|
||||
category?: string;
|
||||
install_count?: number;
|
||||
meta?: Record<string, any>;
|
||||
deleted?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
export interface ManagedAppVersionType {
|
||||
id?: string;
|
||||
fk_managed_app_id?: string;
|
||||
fk_workspace_id?: string;
|
||||
version?: string;
|
||||
version_number?: number;
|
||||
status?: ManagedAppVersionStatus;
|
||||
schema?: string;
|
||||
release_notes?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
export interface ManagedAppDeploymentLogType {
|
||||
id?: string;
|
||||
fk_workspace_id?: string;
|
||||
base_id?: string;
|
||||
fk_managed_app_id?: string;
|
||||
from_version_id?: string;
|
||||
to_version_id?: string;
|
||||
status?: DeploymentStatus;
|
||||
deployment_type?: DeploymentType;
|
||||
error_message?: string;
|
||||
deployment_log?: string;
|
||||
meta?: Record<string, any>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface ManagedAppReqType {
|
||||
title: string;
|
||||
description?: string;
|
||||
visibility?: ManagedAppVisibility;
|
||||
category?: string;
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export enum PlanFeatureTypes {
|
||||
FEATURE_DATE_DEPENDENCY = 'feature_date_dependency',
|
||||
FEATURE_API_COMMENT_V3 = 'feature_api_comment_v3',
|
||||
FEATURE_API_WORKFLOW_MANAGEMENT = 'feature_api_workflow_management',
|
||||
FEATURE_BASE_VARIABLES = 'feature_base_variables',
|
||||
/** On-prem: core EE capability flag — true for all paid plans, false for free */
|
||||
FEATURE_EE_CORE = 'feature_ee_core',
|
||||
FEATURE_TRASH_SETTINGS = 'feature_trash_settings',
|
||||
@@ -386,6 +387,7 @@ export const PlanFeatureUpgradeMessages: Record<PlanFeatureTypes, string> = {
|
||||
[PlanFeatureTypes.FEATURE_DATE_DEPENDENCY]: 'to use date dependencies.',
|
||||
[PlanFeatureTypes.FEATURE_API_COMMENT_V3]: 'to use comment api.',
|
||||
[PlanFeatureTypes.FEATURE_API_WORKFLOW_MANAGEMENT]: 'to use workflow api.',
|
||||
[PlanFeatureTypes.FEATURE_BASE_VARIABLES]: 'to use base variables.',
|
||||
[PlanFeatureTypes.FEATURE_EE_CORE]: 'to access enterprise features.',
|
||||
[PlanFeatureTypes.FEATURE_TRASH_SETTINGS]:
|
||||
'to configure per-table trash settings.',
|
||||
|
||||
+46
-1
@@ -233,6 +233,31 @@ export default abstract class CacheMgr {
|
||||
return this.client.incrby(key, value);
|
||||
}
|
||||
|
||||
async incrbyExpiring(
|
||||
key: string,
|
||||
value: number,
|
||||
seconds: number,
|
||||
): Promise<number> {
|
||||
const results = await this.client
|
||||
.pipeline()
|
||||
.incrby(key, value)
|
||||
.expire(key, seconds)
|
||||
.exec();
|
||||
if (!results || !results[0]) return value;
|
||||
const [err, total] = results[0];
|
||||
if (err) throw err;
|
||||
return Number(total);
|
||||
}
|
||||
|
||||
async setIfNotExist(
|
||||
key: string,
|
||||
value: string,
|
||||
seconds: number,
|
||||
): Promise<boolean> {
|
||||
const res = await this.client.set(key, value, 'EX', seconds, 'NX');
|
||||
return res === 'OK';
|
||||
}
|
||||
|
||||
async getList(
|
||||
scope: string,
|
||||
subKeys: string[],
|
||||
@@ -720,7 +745,27 @@ export default abstract class CacheMgr {
|
||||
|
||||
async processPattern(
|
||||
pattern: string,
|
||||
callback: (key: string | string[]) => Promise<void>,
|
||||
callback: (key: string) => Promise<void>,
|
||||
options?: {
|
||||
count?: number;
|
||||
type?: string;
|
||||
raw?: boolean;
|
||||
batch?: false;
|
||||
},
|
||||
): Promise<void>;
|
||||
async processPattern(
|
||||
pattern: string,
|
||||
callback: (keys: string[]) => Promise<void>,
|
||||
options: {
|
||||
count?: number;
|
||||
type?: string;
|
||||
raw?: boolean;
|
||||
batch: true;
|
||||
},
|
||||
): Promise<void>;
|
||||
async processPattern(
|
||||
pattern: string,
|
||||
callback: (key: any) => Promise<void>,
|
||||
options: {
|
||||
count?: number;
|
||||
type?: string;
|
||||
|
||||
+42
-11
@@ -1,5 +1,6 @@
|
||||
import RedisCacheMgr from './RedisCacheMgr';
|
||||
import RedisMockCacheMgr from './RedisMockCacheMgr';
|
||||
import { isCacheBypassed } from './cacheBypassScope';
|
||||
import type { NcContext } from 'nocodb-sdk';
|
||||
import type CacheMgr from './CacheMgr';
|
||||
import { CACHE_PREFIX, CacheGetType } from '~/utils/globals';
|
||||
@@ -45,7 +46,7 @@ export default class NocoCache {
|
||||
}
|
||||
|
||||
public static async set(context: CacheContext, key, value): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(true);
|
||||
return this.client.set(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
value,
|
||||
@@ -58,7 +59,7 @@ export default class NocoCache {
|
||||
value,
|
||||
expireSeconds,
|
||||
): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(true);
|
||||
return this.client.setExpiring(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
value,
|
||||
@@ -66,6 +67,20 @@ export default class NocoCache {
|
||||
);
|
||||
}
|
||||
|
||||
public static async setIfNotExist(
|
||||
context: CacheContext,
|
||||
key: string,
|
||||
value: string,
|
||||
expireSeconds: number,
|
||||
): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
return this.client.setIfNotExist(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
value,
|
||||
expireSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
public static async incrby(
|
||||
context: CacheContext,
|
||||
key,
|
||||
@@ -78,8 +93,24 @@ export default class NocoCache {
|
||||
);
|
||||
}
|
||||
|
||||
// cache-disabled fallback returns the caller's own delta — for counters
|
||||
// where callers expect a numeric running total, not a boolean success flag.
|
||||
public static async incrbyExpiring(
|
||||
context: CacheContext,
|
||||
key: string,
|
||||
value: number,
|
||||
expireSeconds: number,
|
||||
): Promise<number> {
|
||||
if (this.cacheDisabled) return Promise.resolve(value);
|
||||
return this.client.incrbyExpiring(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
value,
|
||||
expireSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
public static async get(context: CacheContext, key, type): Promise<any> {
|
||||
if (this.cacheDisabled) {
|
||||
if (this.cacheDisabled || isCacheBypassed()) {
|
||||
if (type === CacheGetType.TYPE_ARRAY) return Promise.resolve([]);
|
||||
else if (type === CacheGetType.TYPE_OBJECT) return Promise.resolve(null);
|
||||
return Promise.resolve(null);
|
||||
@@ -112,7 +143,7 @@ export default class NocoCache {
|
||||
list: any[];
|
||||
isNoneList: boolean;
|
||||
}> {
|
||||
if (this.cacheDisabled)
|
||||
if (this.cacheDisabled || isCacheBypassed())
|
||||
return Promise.resolve({
|
||||
list: [],
|
||||
isNoneList: false,
|
||||
@@ -131,7 +162,7 @@ export default class NocoCache {
|
||||
list: any[],
|
||||
props: string[] = [],
|
||||
): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(true);
|
||||
return this.client.setList(
|
||||
`${this.prefix}:${cacheContext(context)}:${scope}`,
|
||||
subListKeys,
|
||||
@@ -159,7 +190,7 @@ export default class NocoCache {
|
||||
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(true);
|
||||
return this.client.appendToList(
|
||||
`${this.prefix}:${cacheContext(context)}:${scope}`,
|
||||
subListKeys,
|
||||
@@ -172,7 +203,7 @@ export default class NocoCache {
|
||||
key: string,
|
||||
updateObj: Record<string, any>,
|
||||
): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(true);
|
||||
return this.client.update(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
updateObj,
|
||||
@@ -187,7 +218,7 @@ export default class NocoCache {
|
||||
ttl?: number;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(true);
|
||||
if (Object.keys(hash).length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -202,7 +233,7 @@ export default class NocoCache {
|
||||
context: CacheContext,
|
||||
key: string,
|
||||
): Promise<Record<string, string | number>> {
|
||||
if (this.cacheDisabled) return Promise.resolve({});
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve({});
|
||||
return this.client.getHash(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
);
|
||||
@@ -213,7 +244,7 @@ export default class NocoCache {
|
||||
key: string,
|
||||
field: string,
|
||||
): Promise<string> {
|
||||
if (this.cacheDisabled) return Promise.resolve(null);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(null);
|
||||
return this.client.getHashField(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
field,
|
||||
@@ -226,7 +257,7 @@ export default class NocoCache {
|
||||
field: string,
|
||||
value: string | number,
|
||||
): Promise<boolean> {
|
||||
if (this.cacheDisabled) return Promise.resolve(true);
|
||||
if (this.cacheDisabled || isCacheBypassed()) return Promise.resolve(true);
|
||||
return !!this.client.setHashField(
|
||||
`${this.prefix}:${cacheContext(context)}:${key}`,
|
||||
field,
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
const scope = new AsyncLocalStorage<true>();
|
||||
|
||||
/**
|
||||
* Run `fn` in a scope where NocoCache read/write ops are bypassed.
|
||||
*
|
||||
* Inside the scope, `get`/`getList`/`getHash`/`getHashField` return empty
|
||||
* values and `set`/`setList`/`setExpiring`/`setHash`/`setHashField`/
|
||||
* `appendToList`/`update` are no-ops — as if the cache were disabled.
|
||||
* Invalidation ops (`del`/`deepDel`/`delHashField`/`clear`) and counters
|
||||
* (`incrby`/`incrHashField`) pass through unchanged.
|
||||
*
|
||||
* Intended for use inside DB transactions where cache reads would return
|
||||
* stale pre-transaction state and cache writes would leak uncommitted
|
||||
* transaction-local data into the shared cache.
|
||||
*
|
||||
* The scope is per-async-tree via AsyncLocalStorage: concurrent requests
|
||||
* outside the scope are unaffected. Nested calls are a safe no-op.
|
||||
*/
|
||||
export const runWithoutCache = <T>(fn: () => Promise<T>): Promise<T> =>
|
||||
scope.run(true, fn);
|
||||
|
||||
export const isCacheBypassed = (): boolean => scope.getStore() === true;
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Canonical names of every traced operation. Defined in CE so the
|
||||
* `@TraceCommand` decorator can be applied directly on CE service methods
|
||||
* without requiring EE-side overrides whose only purpose is to add tracing.
|
||||
*
|
||||
* Contract definitions (EE) use these values as their `name` field, and
|
||||
* service decorators pass them to `@TraceCommand(...)`. The contract is
|
||||
* resolved by name at invocation time via `OperationRegistry.contract(name)`.
|
||||
*
|
||||
* When adding a new traced operation, add the name here first.
|
||||
*/
|
||||
export const OperationName = {
|
||||
// Tables
|
||||
tableCreate: 'tableCreate',
|
||||
tableUpdate: 'tableUpdate',
|
||||
tableDelete: 'tableDelete',
|
||||
|
||||
// Columns
|
||||
columnAdd: 'columnAdd',
|
||||
columnUpdate: 'columnUpdate',
|
||||
columnDelete: 'columnDelete',
|
||||
columnSetAsPrimary: 'columnSetAsPrimary',
|
||||
|
||||
// Views (generic)
|
||||
viewUpdate: 'viewUpdate',
|
||||
viewDelete: 'viewDelete',
|
||||
|
||||
// Per-type view ops
|
||||
gridViewCreate: 'gridViewCreate',
|
||||
gridViewUpdate: 'gridViewUpdate',
|
||||
formViewCreate: 'formViewCreate',
|
||||
formViewUpdate: 'formViewUpdate',
|
||||
galleryViewCreate: 'galleryViewCreate',
|
||||
galleryViewUpdate: 'galleryViewUpdate',
|
||||
kanbanViewCreate: 'kanbanViewCreate',
|
||||
kanbanViewUpdate: 'kanbanViewUpdate',
|
||||
calendarViewCreate: 'calendarViewCreate',
|
||||
calendarViewUpdate: 'calendarViewUpdate',
|
||||
listViewCreate: 'listViewCreate',
|
||||
listViewUpdate: 'listViewUpdate',
|
||||
timelineViewCreate: 'timelineViewCreate',
|
||||
timelineViewUpdate: 'timelineViewUpdate',
|
||||
mapViewCreate: 'mapViewCreate',
|
||||
mapViewUpdate: 'mapViewUpdate',
|
||||
|
||||
// View columns + visibility
|
||||
viewColumnUpdate: 'viewColumnUpdate',
|
||||
gridColumnUpdate: 'gridColumnUpdate',
|
||||
formColumnUpdate: 'formColumnUpdate',
|
||||
|
||||
// Sorts
|
||||
sortCreate: 'sortCreate',
|
||||
sortUpdate: 'sortUpdate',
|
||||
sortDelete: 'sortDelete',
|
||||
|
||||
// Filters
|
||||
filterCreate: 'filterCreate',
|
||||
filterUpdate: 'filterUpdate',
|
||||
filterDelete: 'filterDelete',
|
||||
filterCreateV3: 'filterCreateV3',
|
||||
filterReplaceV3: 'filterReplaceV3',
|
||||
filterDeleteAllV3: 'filterDeleteAllV3',
|
||||
linkFilterCreate: 'linkFilterCreate',
|
||||
widgetFilterCreate: 'widgetFilterCreate',
|
||||
rowColorConditionsCreate: 'rowColorConditionsCreate',
|
||||
|
||||
// Hooks
|
||||
hookCreate: 'hookCreate',
|
||||
hookUpdate: 'hookUpdate',
|
||||
hookDelete: 'hookDelete',
|
||||
|
||||
// Extensions
|
||||
extensionCreate: 'extensionCreate',
|
||||
extensionUpdate: 'extensionUpdate',
|
||||
extensionDelete: 'extensionDelete',
|
||||
|
||||
// Dashboards + widgets
|
||||
dashboardCreate: 'dashboardCreate',
|
||||
dashboardUpdate: 'dashboardUpdate',
|
||||
dashboardDelete: 'dashboardDelete',
|
||||
widgetCreate: 'widgetCreate',
|
||||
widgetUpdate: 'widgetUpdate',
|
||||
widgetDelete: 'widgetDelete',
|
||||
duplicateWidget: 'duplicateWidget',
|
||||
|
||||
// Scripts
|
||||
scriptCreate: 'scriptCreate',
|
||||
scriptUpdate: 'scriptUpdate',
|
||||
scriptDelete: 'scriptDelete',
|
||||
|
||||
// Workflows
|
||||
workflowCreate: 'workflowCreate',
|
||||
workflowUpdate: 'workflowUpdate',
|
||||
workflowDelete: 'workflowDelete',
|
||||
workflowPublish: 'workflowPublish',
|
||||
|
||||
// View sections
|
||||
viewSectionCreate: 'viewSectionCreate',
|
||||
viewSectionUpdate: 'viewSectionUpdate',
|
||||
viewSectionDelete: 'viewSectionDelete',
|
||||
|
||||
// Record templates
|
||||
recordTemplateCreate: 'recordTemplateCreate',
|
||||
recordTemplateUpdate: 'recordTemplateUpdate',
|
||||
recordTemplateDelete: 'recordTemplateDelete',
|
||||
|
||||
// Sync
|
||||
syncCreate: 'syncCreate',
|
||||
syncUpdate: 'syncUpdate',
|
||||
syncDelete: 'syncDelete',
|
||||
|
||||
// Date dependency
|
||||
dateDependencyUpdate: 'dateDependencyUpdate',
|
||||
dateDependencyDelete: 'dateDependencyDelete',
|
||||
|
||||
// Base variables
|
||||
baseVariableCreate: 'baseVariableCreate',
|
||||
baseVariableUpdate: 'baseVariableUpdate',
|
||||
baseVariableDelete: 'baseVariableDelete',
|
||||
} as const;
|
||||
|
||||
export type OperationName = (typeof OperationName)[keyof typeof OperationName];
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CommandHandler, OperationContract } from './types';
|
||||
|
||||
// CE no-op stub. EE overrides with the real Map-backed singleton.
|
||||
// `OperationContract<any>` (not the default `OperationContract<ZodTypeAny>`)
|
||||
// avoids a variance error: zod schema fields propagate `S` into function-type
|
||||
// positions (`EntityRefFn<S>`), and a specific `OperationContract<typeof
|
||||
// mySchema>` would otherwise be rejected as not assignable to the parameter.
|
||||
class _OperationRegistryNoop {
|
||||
register<C extends OperationContract<any>>(_c: C, _h: CommandHandler<C>) {}
|
||||
freeze() {}
|
||||
resolve(_name: string, _version: number) {
|
||||
return undefined;
|
||||
}
|
||||
describe(): Array<{
|
||||
name: string;
|
||||
version: number;
|
||||
entity: string;
|
||||
idField?: string;
|
||||
schemaHash: string;
|
||||
extraSchemaHash?: string;
|
||||
}> {
|
||||
return [];
|
||||
}
|
||||
contract(_name: string, _version: number) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const OperationRegistry = new _OperationRegistryNoop();
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { z, ZodTypeAny } from 'zod';
|
||||
import type { MetaTable } from '~/utils/globals';
|
||||
import type { NcContext, NcRequest } from '~/interface/config';
|
||||
import type { OperationName } from './op-names';
|
||||
|
||||
/**
|
||||
* Versioned, typed declaration of a single state-mutating operation. Each
|
||||
* contract is the single source of truth for:
|
||||
*
|
||||
* - Identity — `name` + `version` (registry lookup key, also used
|
||||
* as the `event` column in `nc_sandbox_changelog`).
|
||||
* - Param shape — `schema` validates and shapes the persisted params at
|
||||
* record time (strict — throws on mismatch). `extraSchema`
|
||||
* validates handler-side metadata threaded via `extra`.
|
||||
* - Entity reference — `entityId` / `entityTitle` / `parentId` / `parentTitle`
|
||||
* extract identifying fields from `param` or the service
|
||||
* result for changelog rows. Fall back to `idField`
|
||||
* (a key in `param` whose value is the created entity).
|
||||
* - Replay context — `resolveCtx` runs before the original method to capture
|
||||
* pre-state (e.g. old title for rename detection).
|
||||
* `skipIf` runs after, suppressing record when the call
|
||||
* was a no-op.
|
||||
* - Cross-refs — `deps` returns related entity IDs so replay can stitch
|
||||
* foreign-key references across the merge boundary.
|
||||
* `extraCommandMeta` packs additional payload (e.g.
|
||||
* sandbox column IDs) onto the changelog row.
|
||||
* - Description — human-readable summary for audit UIs (string or DescFn
|
||||
* receiving entity/parent titles).
|
||||
*
|
||||
* Add new contracts under `src/ee/command-registry/operations/` and register
|
||||
* a handler in the matching `handlers/` file — see `CLAUDE.md` for the full
|
||||
* end-to-end checklist.
|
||||
*/
|
||||
export interface OperationContract<S extends ZodTypeAny = ZodTypeAny> {
|
||||
readonly name: OperationName;
|
||||
readonly version: number;
|
||||
readonly entity: MetaTable;
|
||||
|
||||
readonly schema: S;
|
||||
readonly extraSchema?: ZodTypeAny;
|
||||
|
||||
readonly idField?: string;
|
||||
|
||||
readonly entityId?: string | EntityRefFn<S>;
|
||||
readonly entityTitle?: string | EntityRefFn<S>;
|
||||
readonly parentId?: string | EntityRefFn<S>;
|
||||
readonly parentTitle?: string | EntityRefFn<S>;
|
||||
readonly description?: string | DescFn;
|
||||
|
||||
readonly resolveCtx?: (ctx: NcContext, p: z.infer<S>) => Promise<ResolvedCtx>;
|
||||
readonly skipIf?: (
|
||||
ctx: NcContext,
|
||||
p: z.infer<S>,
|
||||
r: any,
|
||||
resolved?: ResolvedCtx,
|
||||
) => Promise<boolean> | boolean;
|
||||
readonly deps?: (p: z.infer<S>, r: any) => TraceCommandDep[];
|
||||
readonly extraCommandMeta?: (
|
||||
p: z.infer<S>,
|
||||
r: any,
|
||||
) => Record<string, any> | undefined;
|
||||
}
|
||||
|
||||
export type EntityRefFn<S extends ZodTypeAny> = (
|
||||
p: z.infer<S>,
|
||||
r: any,
|
||||
) => string | undefined;
|
||||
|
||||
export type ParamsOf<C> = C extends OperationContract<infer S>
|
||||
? z.infer<S>
|
||||
: never;
|
||||
|
||||
export type CommandHandler<
|
||||
C extends OperationContract<any> = OperationContract<any>,
|
||||
> = (ctx: NcContext, params: ParamsOf<C>, meta: HandlerMeta) => Promise<unknown>;
|
||||
|
||||
export interface HandlerMeta {
|
||||
entryId: string;
|
||||
entityId?: string;
|
||||
originalReq: NcRequest;
|
||||
createdBy: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChangelogCommandPayload {
|
||||
name: string;
|
||||
version: number;
|
||||
params: unknown;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TraceCommandDep {
|
||||
entity: MetaTable;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ResolvedCtx {
|
||||
entityTitle?: string;
|
||||
parentEntityTitle?: string;
|
||||
extra?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DescCtx {
|
||||
entityTitle?: string;
|
||||
parentEntityTitle?: string;
|
||||
operation: string;
|
||||
extra?: Record<string, any>;
|
||||
}
|
||||
export type DescFn = (ctx: DescCtx) => string;
|
||||
@@ -21,14 +21,12 @@ import type { NcRequest } from '~/interface/config';
|
||||
import { NcContext } from '~/interface/config';
|
||||
import { GlobalGuard } from '~/guards/global/global.guard';
|
||||
import { AttachmentsService } from '~/services/attachments.service';
|
||||
import { Column, PresignedUrl } from '~/models';
|
||||
import { PresignedUrl } from '~/models';
|
||||
import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor';
|
||||
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
|
||||
import { DataTableService } from '~/services/data-table.service';
|
||||
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
|
||||
import { TenantContext } from '~/decorators/tenant-context.decorator';
|
||||
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
|
||||
import { NcError } from '~/helpers/catchError';
|
||||
import { ATTACHMENT_ROOTS, localFileExists } from '~/helpers/attachmentHelpers';
|
||||
import { NC_DATA_IMPORT_FILE_SIZE } from '~/constants';
|
||||
|
||||
@@ -36,7 +34,6 @@ import { NC_DATA_IMPORT_FILE_SIZE } from '~/constants';
|
||||
export class AttachmentsSecureController {
|
||||
constructor(
|
||||
private readonly attachmentsService: AttachmentsService,
|
||||
private readonly dataTableService: DataTableService,
|
||||
) {}
|
||||
|
||||
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
|
||||
@@ -152,30 +149,10 @@ export class AttachmentsSecureController {
|
||||
@Param('rowId') rowId: string,
|
||||
@Query('urlOrPath') urlOrPath: string,
|
||||
) {
|
||||
const column = await Column.get(context, {
|
||||
colId: columnId,
|
||||
});
|
||||
|
||||
if (!column) {
|
||||
NcError.fieldNotFound(columnId);
|
||||
}
|
||||
|
||||
const record = await this.dataTableService.dataRead(context, {
|
||||
baseId: context.base_id,
|
||||
return this.attachmentsService.downloadAttachment(context, {
|
||||
modelId,
|
||||
columnId,
|
||||
rowId,
|
||||
query: {
|
||||
fields: column.title,
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
NcError.recordNotFound(rowId);
|
||||
}
|
||||
|
||||
return this.attachmentsService.getAttachmentFromRecord({
|
||||
record,
|
||||
column,
|
||||
urlOrPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,19 +29,15 @@ import {
|
||||
isPreviewAllowed,
|
||||
localFileExists,
|
||||
} from '~/helpers/attachmentHelpers';
|
||||
import { DataTableService } from '~/services/data-table.service';
|
||||
import { TenantContext } from '~/decorators/tenant-context.decorator';
|
||||
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
|
||||
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
|
||||
import { Column } from '~/models';
|
||||
import { NcError } from '~/helpers/catchError';
|
||||
import { NC_DATA_IMPORT_FILE_SIZE } from '~/constants';
|
||||
|
||||
@Controller()
|
||||
export class AttachmentsController {
|
||||
constructor(
|
||||
private readonly attachmentsService: AttachmentsService,
|
||||
private readonly dataTableService: DataTableService,
|
||||
) {}
|
||||
|
||||
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
|
||||
@@ -251,30 +247,10 @@ export class AttachmentsController {
|
||||
@Param('rowId') rowId: string,
|
||||
@Query('urlOrPath') urlOrPath: string,
|
||||
) {
|
||||
const column = await Column.get(context, {
|
||||
colId: columnId,
|
||||
});
|
||||
|
||||
if (!column) {
|
||||
NcError.fieldNotFound(columnId);
|
||||
}
|
||||
|
||||
const record = await this.dataTableService.dataRead(context, {
|
||||
baseId: context.base_id,
|
||||
return this.attachmentsService.downloadAttachment(context, {
|
||||
modelId,
|
||||
columnId,
|
||||
rowId,
|
||||
query: {
|
||||
fields: column.title,
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
NcError.recordNotFound(rowId);
|
||||
}
|
||||
|
||||
return this.attachmentsService.getAttachmentFromRecord({
|
||||
record,
|
||||
column,
|
||||
urlOrPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { OPERATION_SCOPES } from '~/controllers/internal/operationScopes';
|
||||
import type { NcContext, NcRequest } from 'nocodb-sdk';
|
||||
import type {
|
||||
InternalApiModule,
|
||||
InternalGETResponseType,
|
||||
} from '~/utils/internal-type';
|
||||
import { AttachmentsService } from '~/services/attachments.service';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentGetOperations
|
||||
implements InternalApiModule<InternalGETResponseType>
|
||||
{
|
||||
constructor(
|
||||
protected readonly attachmentsService: AttachmentsService,
|
||||
) {}
|
||||
|
||||
operations = ['attachmentDownload' as const];
|
||||
httpMethod = 'GET' as const;
|
||||
|
||||
async handle(
|
||||
context: NcContext,
|
||||
{
|
||||
req,
|
||||
operation,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
baseId: string;
|
||||
operation: keyof typeof OPERATION_SCOPES;
|
||||
payload: any;
|
||||
req: NcRequest;
|
||||
},
|
||||
): InternalGETResponseType {
|
||||
switch (operation) {
|
||||
case 'attachmentDownload':
|
||||
return await this.attachmentsService.downloadAttachment(context, {
|
||||
modelId: req.query.modelId as string,
|
||||
columnId: req.query.columnId as string,
|
||||
rowId: req.query.rowId as string,
|
||||
urlOrPath: req.query.urlOrPath as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export const OPERATION_SCOPES = {
|
||||
oAuthClientRegenerateSecret: 'org',
|
||||
checkDependency: 'base',
|
||||
|
||||
// Attachment Operations
|
||||
attachmentDownload: 'base',
|
||||
|
||||
// UI Get Operations
|
||||
tableGet: 'base',
|
||||
columnsHash: 'base',
|
||||
@@ -157,6 +160,7 @@ export const OPERATION_SCOPES = {
|
||||
managedAppDeployments: 'base',
|
||||
managedAppVersionDeployments: 'base',
|
||||
managedAppDeploymentLogs: 'base',
|
||||
managedAppManualUpdate: 'base',
|
||||
|
||||
// List View
|
||||
listViewDataList: 'base',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BaseListAllGetOperations } from '~/controllers/internal/modules/BaseLis
|
||||
import { InstanceAdminGetOperations } from '~/controllers/internal/modules/InstanceAdminGet.operations';
|
||||
import { IntegrationGetOperations } from '~/controllers/internal/modules/IntegrationGet.operations';
|
||||
import { IntegrationPostOperations } from '~/controllers/internal/modules/IntegrationPost.operations';
|
||||
import { AttachmentGetOperations } from '~/controllers/internal/modules/AttachmentGet.operations';
|
||||
import { INTERNAL_API_MODULE_PROVIDER_KEY } from '~/utils/internal-type';
|
||||
|
||||
export const InternalApiModules = [
|
||||
@@ -26,6 +27,7 @@ export const InternalApiModules = [
|
||||
InstanceAdminGetOperations,
|
||||
IntegrationGetOperations,
|
||||
IntegrationPostOperations,
|
||||
AttachmentGetOperations,
|
||||
];
|
||||
|
||||
export const InternalApiModuleProvider = {
|
||||
|
||||
@@ -66,6 +66,7 @@ export class TablesV3Controller {
|
||||
sourceId: sourceId,
|
||||
table: body,
|
||||
user: req.user,
|
||||
req,
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -175,6 +175,31 @@ const MAX_RECURSION_DEPTH = 2;
|
||||
const SELECT_REGEX = /^(\(|)select/i;
|
||||
const INSERT_REGEX = /^(\(|)insert/i;
|
||||
|
||||
export interface ExecAndParseOptions {
|
||||
skipDateConversion?: boolean;
|
||||
skipAttachmentConversion?: boolean;
|
||||
skipSubstitutingColumnIds?: boolean;
|
||||
skipUserConversion?: boolean;
|
||||
skipJsonConversion?: boolean;
|
||||
raw?: boolean;
|
||||
first?: boolean;
|
||||
bulkAggregate?: boolean;
|
||||
apiVersion?: NcApiVersion;
|
||||
}
|
||||
|
||||
/** Args stashed on DataLoader instances for relation queries (hm/mm/bt/oo). */
|
||||
interface RelationLoaderArgs {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
fieldsSet?: Set<string>;
|
||||
fieldSet?: Set<string>;
|
||||
}
|
||||
|
||||
/** DataLoader with a typed side-channel for query args. */
|
||||
class DataLoaderWithArgs<K, V> extends DataLoader<K, V> {
|
||||
args?: RelationLoaderArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for models
|
||||
*
|
||||
@@ -545,7 +570,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
validateFormula = false,
|
||||
): Promise<any> {
|
||||
const columns = await this.model.getColumns(this.context);
|
||||
const { where, ...rest } = this._getListArgs(args as any);
|
||||
const { where, ...rest } = this._getListArgs(args);
|
||||
const qb = this.dbDriver(this.tnPath);
|
||||
await this.selectObject({ ...args, qb, validateFormula, columns });
|
||||
|
||||
@@ -661,7 +686,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
|
||||
const columns = await this.model.getColumns(this.context);
|
||||
|
||||
const { where, fields, ...rest } = this._getListArgs(args as any);
|
||||
const { where, fields, ...rest } = this._getListArgs(args);
|
||||
|
||||
const qb = this.dbDriver(this.tnPath);
|
||||
|
||||
@@ -997,7 +1022,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
) {
|
||||
const columns = await this.model.getColumns(this.context);
|
||||
|
||||
const { where, ...rest } = this._getListArgs(args as any);
|
||||
const { where, ...rest } = this._getListArgs(args);
|
||||
|
||||
const qb = this.dbDriver(this.tnPath);
|
||||
const aggregateStatement = `${aggregateColumnName} as ${aggregateFn}__${aggregateColumnName}`;
|
||||
@@ -1140,7 +1165,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { where, aggregation } = this._getListArgs(args as any);
|
||||
const { where, aggregation } = this._getListArgs(args);
|
||||
|
||||
const columns = await this.model.getColumns(this.context);
|
||||
|
||||
@@ -1363,7 +1388,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
|
||||
async aggregate(args: { filterArr?: Filter[]; where?: string }, view?: View) {
|
||||
try {
|
||||
const { where, aggregation } = this._getListArgs(args as any);
|
||||
const { where, aggregation } = this._getListArgs(args);
|
||||
|
||||
const columns = await this.model.getColumns(this.context);
|
||||
|
||||
@@ -1772,7 +1797,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
where: string;
|
||||
filters?: Filter[];
|
||||
qb;
|
||||
sort?: string;
|
||||
sort?: string | string[];
|
||||
onlySort?: boolean;
|
||||
skipViewFilter?: boolean;
|
||||
skipSort?: boolean;
|
||||
@@ -1980,18 +2005,18 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
// DataLoader collects all .load(id) calls from the same microtick
|
||||
// into a single batch. The batch callback is wrapped in _queryQueue.add()
|
||||
// to serialize actual DB execution across all relation types.
|
||||
const listLoader = new DataLoader(
|
||||
(ids: string[]) =>
|
||||
const listLoader = new DataLoaderWithArgs(
|
||||
(ids: readonly string[]) =>
|
||||
this._queryQueue.add(async () => {
|
||||
if (ids.length > 1) {
|
||||
const data = await this.multipleHmList(
|
||||
{
|
||||
colId: column.id,
|
||||
ids,
|
||||
ids: ids as string[],
|
||||
apiVersion,
|
||||
linksAsLtar,
|
||||
},
|
||||
(listLoader as any).args,
|
||||
listLoader.args,
|
||||
);
|
||||
return ids.map((id: string) =>
|
||||
data[id] ? data[id] : [],
|
||||
@@ -2006,7 +2031,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
nested: true,
|
||||
linksAsLtar,
|
||||
},
|
||||
(listLoader as any).args,
|
||||
listLoader.args,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -2021,8 +2046,8 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
column.uidt === UITypes.Links && !linksAsLtar
|
||||
? `_nc_lk_${column.title}`
|
||||
: column.title
|
||||
] = async function (args): Promise<any> {
|
||||
(listLoader as any).args = args;
|
||||
] = async function (args?: RelationLoaderArgs): Promise<any> {
|
||||
listLoader.args = args;
|
||||
return listLoader.load(
|
||||
getCompositePkValue(self.model.primaryKeys, this),
|
||||
);
|
||||
@@ -2030,8 +2055,8 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
} else if (isBtLikeV2Junction(column)) {
|
||||
// V2 MO/OO: single-record — return object (like BT)
|
||||
// Use multipleMmList for batching, take first record per parent
|
||||
const readLoader = new DataLoader(
|
||||
(ids: string[]) =>
|
||||
const readLoader = new DataLoaderWithArgs(
|
||||
(ids: readonly string[]) =>
|
||||
this._queryQueue.add(async () => {
|
||||
if (ids?.length > 1) {
|
||||
const lists = await this.multipleMmList(
|
||||
@@ -2039,14 +2064,14 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
parentIds: ids as string[],
|
||||
colId: column.id,
|
||||
},
|
||||
(readLoader as any).args,
|
||||
readLoader.args,
|
||||
);
|
||||
return lists.map((list) => list?.[0] ?? null);
|
||||
} else {
|
||||
return [
|
||||
await this.mmRead(
|
||||
{ parentId: ids[0], colId: column.id },
|
||||
(readLoader as any).args,
|
||||
readLoader.args,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -2057,26 +2082,28 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
);
|
||||
|
||||
const self: BaseModelSqlv2 = this;
|
||||
proto[column.title] = async function (args?: any) {
|
||||
(readLoader as any).args = args;
|
||||
proto[column.title] = async function (
|
||||
args?: RelationLoaderArgs,
|
||||
) {
|
||||
readLoader.args = args;
|
||||
return await readLoader.load(
|
||||
getCompositePkValue(self.model.primaryKeys, this),
|
||||
);
|
||||
};
|
||||
} else if (colOptions.type === 'mm' || isMMLike) {
|
||||
const listLoader = new DataLoader(
|
||||
(ids: string[]) =>
|
||||
const listLoader = new DataLoaderWithArgs(
|
||||
(ids: readonly string[]) =>
|
||||
this._queryQueue.add(async () => {
|
||||
if (ids?.length > 1) {
|
||||
const data = await this.multipleMmList(
|
||||
{
|
||||
parentIds: ids,
|
||||
parentIds: ids as string[],
|
||||
colId: column.id,
|
||||
apiVersion,
|
||||
nested: true,
|
||||
linksAsLtar,
|
||||
},
|
||||
(listLoader as any).args,
|
||||
listLoader.args,
|
||||
);
|
||||
|
||||
return data;
|
||||
@@ -2090,7 +2117,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
nested: true,
|
||||
linksAsLtar,
|
||||
},
|
||||
(listLoader as any).args,
|
||||
listLoader.args,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -2105,8 +2132,8 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
column.uidt === UITypes.Links && !linksAsLtar
|
||||
? `_nc_lk_${column.title}`
|
||||
: column.title
|
||||
] = async function (args): Promise<any> {
|
||||
(listLoader as any).args = args;
|
||||
] = async function (args?: RelationLoaderArgs): Promise<any> {
|
||||
listLoader.args = args;
|
||||
return await listLoader.load(
|
||||
getCompositePkValue(self.model.primaryKeys, this),
|
||||
);
|
||||
@@ -2128,8 +2155,8 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
// it takes individual keys and callback is invoked with an array of values and we can get the
|
||||
// result for all those together and return the value in the same order as in the array
|
||||
// this way all parents data extracted together
|
||||
const readLoader = new DataLoader(
|
||||
(_ids: string[]) =>
|
||||
const readLoader = new DataLoaderWithArgs(
|
||||
(_ids: readonly string[]) =>
|
||||
this._queryQueue.add(async () => {
|
||||
// handle binary(16) foreign keys
|
||||
const ids = _ids.map((id) => {
|
||||
@@ -2163,7 +2190,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
})
|
||||
).list(
|
||||
{
|
||||
fieldsSet: (readLoader as any).args?.fieldsSet,
|
||||
fieldsSet: readLoader.args?.fieldsSet,
|
||||
filterArr: [
|
||||
new Filter({
|
||||
id: null,
|
||||
@@ -2191,14 +2218,16 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
);
|
||||
|
||||
// defining BelongsTo read resolver method
|
||||
proto[column.title] = async function (args?: any) {
|
||||
proto[column.title] = async function (
|
||||
args?: RelationLoaderArgs,
|
||||
) {
|
||||
if (
|
||||
this?.[cCol?.title] === null ||
|
||||
this?.[cCol?.title] === undefined
|
||||
)
|
||||
return null;
|
||||
|
||||
(readLoader as any).args = args;
|
||||
readLoader.args = args;
|
||||
|
||||
return await readLoader.load(this?.[cCol?.title]);
|
||||
};
|
||||
@@ -2221,8 +2250,8 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
// it takes individual keys and callback is invoked with an array of values and we can get the
|
||||
// result for all those together and return the value in the same order as in the array
|
||||
// this way all parents data extracted together
|
||||
const readLoader = new DataLoader(
|
||||
(_ids: string[]) =>
|
||||
const readLoader = new DataLoaderWithArgs(
|
||||
(_ids: readonly string[]) =>
|
||||
this._queryQueue.add(async () => {
|
||||
// handle binary(16) foreign keys
|
||||
const ids = _ids.map((id) => {
|
||||
@@ -2257,7 +2286,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
})
|
||||
).list(
|
||||
{
|
||||
fieldsSet: (readLoader as any).args?.fieldsSet,
|
||||
fieldsSet: readLoader.args?.fieldsSet,
|
||||
filterArr: [
|
||||
new Filter({
|
||||
id: null,
|
||||
@@ -2285,28 +2314,30 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
);
|
||||
|
||||
// defining BelongsTo read resolver method
|
||||
proto[column.title] = async function (args?: any) {
|
||||
proto[column.title] = async function (
|
||||
args?: RelationLoaderArgs,
|
||||
) {
|
||||
if (
|
||||
this?.[cCol?.title] === null ||
|
||||
this?.[cCol?.title] === undefined
|
||||
)
|
||||
return null;
|
||||
|
||||
(readLoader as any).args = args;
|
||||
readLoader.args = args;
|
||||
|
||||
return await readLoader.load(this?.[cCol?.title]);
|
||||
};
|
||||
} else {
|
||||
const listLoader = new DataLoader(
|
||||
(ids: string[]) =>
|
||||
const listLoader = new DataLoaderWithArgs(
|
||||
(ids: readonly string[]) =>
|
||||
this._queryQueue.add(async () => {
|
||||
if (ids.length > 1) {
|
||||
const data = await this.multipleHmList(
|
||||
{
|
||||
colId: column.id,
|
||||
ids,
|
||||
ids: ids as string[],
|
||||
},
|
||||
(listLoader as any).args,
|
||||
listLoader.args,
|
||||
);
|
||||
return ids.map((id: string) =>
|
||||
data[id] ? data[id]?.[0] : null,
|
||||
@@ -2319,7 +2350,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
colId: column.id,
|
||||
id: ids[0],
|
||||
},
|
||||
(listLoader as any).args,
|
||||
listLoader.args,
|
||||
)
|
||||
)?.[0] ?? null,
|
||||
];
|
||||
@@ -2335,8 +2366,8 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
column.uidt === UITypes.Links && !linksAsLtar
|
||||
? `_nc_lk_${column.title}`
|
||||
: column.title
|
||||
] = async function (args): Promise<any> {
|
||||
(listLoader as any).args = args;
|
||||
] = async function (args?: RelationLoaderArgs): Promise<any> {
|
||||
listLoader.args = args;
|
||||
return listLoader.load(
|
||||
getCompositePkValue(self.model.primaryKeys, this),
|
||||
);
|
||||
@@ -2946,6 +2977,14 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
|
||||
await this.prepareNocoData(updateObj, false, cookie, prevData);
|
||||
|
||||
// Reject empty payloads explicitly — knex would otherwise throw
|
||||
// "Empty .update() call detected" with no usable context for the user.
|
||||
if (!updateObj || Object.keys(updateObj).length === 0) {
|
||||
NcError.get(this.context).invalidRequestBody(
|
||||
'No valid fields provided in update payload',
|
||||
);
|
||||
}
|
||||
|
||||
const query = this.dbDriver(this.tnPath)
|
||||
.update(updateObj)
|
||||
.where(await this._wherePk(id, true));
|
||||
@@ -3435,7 +3474,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
*/
|
||||
public async findByMergeFields(
|
||||
mergeColumns: Column[],
|
||||
mergeValuesPerRecord: any[][],
|
||||
mergeValuesPerRecord: unknown[][],
|
||||
): Promise<Record<string, any>[]> {
|
||||
if (mergeValuesPerRecord.length === 0) return [];
|
||||
|
||||
@@ -3445,7 +3484,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
|
||||
// Deduplicate merge value tuples
|
||||
const seen = new Set<string>();
|
||||
const uniqueTuples: any[][] = [];
|
||||
const uniqueTuples: unknown[][] = [];
|
||||
for (const tuple of mergeValuesPerRecord) {
|
||||
const key = tuple
|
||||
.map((v) => (v === null ? '\0NULL\0' : String(v)))
|
||||
@@ -3982,7 +4021,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
|
||||
// Helper method to format date
|
||||
private formatDate(val: string): any {
|
||||
private formatDate(val: string): Knex.Raw | string {
|
||||
const { isMySQL, isSqlite, isPg } = this.clientMeta;
|
||||
if (val.indexOf('-') < 0 && val.indexOf('+') < 0 && val.slice(-1) !== 'Z') {
|
||||
// if no timezone is given,
|
||||
@@ -4980,17 +5019,17 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
* */
|
||||
|
||||
public async handleRichTextMentions(
|
||||
_prevData,
|
||||
_newData: Record<string, any> | Array<Record<string, any>>,
|
||||
_req,
|
||||
_prevData: Record<string, any> | Record<string, any>[] | null,
|
||||
_newData: Record<string, any> | Record<string, any>[],
|
||||
_req: NcRequest,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
public async beforeInsert(
|
||||
data: any,
|
||||
data: Record<string, any>,
|
||||
_trx: any,
|
||||
req,
|
||||
req: NcRequest,
|
||||
params?: {
|
||||
allowSystemColumn?: boolean;
|
||||
},
|
||||
@@ -5008,9 +5047,9 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
|
||||
public async beforeBulkInsert(
|
||||
data: any,
|
||||
data: Record<string, any>[],
|
||||
_trx: any,
|
||||
req,
|
||||
req: NcRequest,
|
||||
params?: {
|
||||
allowSystemColumn?: boolean;
|
||||
},
|
||||
@@ -5033,8 +5072,8 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
trx: _trx,
|
||||
req,
|
||||
}: {
|
||||
data: any;
|
||||
insertData: any;
|
||||
data: Record<string, any>;
|
||||
insertData: Record<string, any>;
|
||||
trx: any;
|
||||
req: NcRequest;
|
||||
}): Promise<void> {
|
||||
@@ -5076,7 +5115,11 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
await this.handleRichTextMentions(null, data, req);
|
||||
}
|
||||
|
||||
public async afterBulkInsert(data: any[], _trx: any, req): Promise<void> {
|
||||
public async afterBulkInsert(
|
||||
data: Record<string, any>[],
|
||||
_trx: any,
|
||||
req: NcRequest,
|
||||
): Promise<void> {
|
||||
await this.handleHooks('after.bulkInsert', null, data, req);
|
||||
let parentAuditId;
|
||||
|
||||
@@ -5150,9 +5193,9 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
|
||||
public async afterDelete(
|
||||
data: any,
|
||||
data: Record<string, any>,
|
||||
_trx: any,
|
||||
req,
|
||||
req: NcRequest,
|
||||
eventType: AuditV1OperationTypes = AuditV1OperationTypes.DATA_DELETE,
|
||||
): Promise<void> {
|
||||
const id = this.extractPksValues(data);
|
||||
@@ -5180,9 +5223,9 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
|
||||
public async afterBulkDelete(
|
||||
data: any,
|
||||
data: Record<string, any>[],
|
||||
_trx: any,
|
||||
req,
|
||||
req: NcRequest,
|
||||
isBulkAllOperation = false,
|
||||
bulkEventType: AuditV1OperationTypes = AuditV1OperationTypes.DATA_BULK_DELETE,
|
||||
rowEventType: AuditV1OperationTypes = AuditV1OperationTypes.DATA_DELETE,
|
||||
@@ -5322,17 +5365,19 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
|
||||
public async afterBulkUpdate(
|
||||
prevData: any,
|
||||
newData: any,
|
||||
prevData: Record<string, any>[] | null,
|
||||
newData: Record<string, any>[] | number,
|
||||
_trx: any,
|
||||
req,
|
||||
req: NcRequest,
|
||||
isBulkAllOperation = false,
|
||||
): Promise<void> {
|
||||
if (!isBulkAllOperation) {
|
||||
if (!isBulkAllOperation && Array.isArray(newData)) {
|
||||
await this.handleHooks('after.bulkUpdate', prevData, newData, req);
|
||||
}
|
||||
|
||||
if (newData && newData.length > 0) {
|
||||
if (!Array.isArray(newData)) return;
|
||||
|
||||
if (newData.length > 0) {
|
||||
const parentAuditId = await Noco.ncAudit.genNanoid(MetaTable.AUDIT);
|
||||
|
||||
// disable external source audit in cloud
|
||||
@@ -5433,7 +5478,11 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
await this.handleRichTextMentions(prevData, newData, req);
|
||||
}
|
||||
|
||||
public async beforeUpdate(data: any, _trx: any, req): Promise<void> {
|
||||
public async beforeUpdate(
|
||||
data: Record<string, any>,
|
||||
_trx: any,
|
||||
req: NcRequest,
|
||||
): Promise<void> {
|
||||
const ignoreWebhook = req.query?.ignoreWebhook;
|
||||
if (ignoreWebhook) {
|
||||
if (ignoreWebhook != 'true' && ignoreWebhook != 'false') {
|
||||
@@ -5448,10 +5497,10 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
|
||||
public async afterUpdate(
|
||||
prevData: any,
|
||||
newData: any,
|
||||
prevData: Record<string, any>,
|
||||
newData: Record<string, any>,
|
||||
_trx: any,
|
||||
req,
|
||||
req: NcRequest,
|
||||
updateObj?: Record<string, any>,
|
||||
): Promise<void> {
|
||||
// TODO this is a temporary fix for the audit log / DOMPurify causes issue for long text
|
||||
@@ -5530,7 +5579,11 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
await this.handleRichTextMentions(prevData, newData, req);
|
||||
}
|
||||
|
||||
public async beforeDelete(data: any, _trx: any, req): Promise<void> {
|
||||
public async beforeDelete(
|
||||
data: Record<string, any>,
|
||||
_trx: any,
|
||||
req: NcRequest,
|
||||
): Promise<void> {
|
||||
if (this.model.synced) {
|
||||
NcError.get(this.context).prohibitedSyncTableOperation({
|
||||
modelName: this.model.title,
|
||||
@@ -5541,7 +5594,11 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
await this.handleHooks('before.delete', null, data, req);
|
||||
}
|
||||
|
||||
public async beforeBulkDelete(_data: any, _trx: any, _req): Promise<void> {
|
||||
public async beforeBulkDelete(
|
||||
_data: Record<string, any>[],
|
||||
_trx: any,
|
||||
_req: NcRequest,
|
||||
): Promise<void> {
|
||||
if (this.model.synced) {
|
||||
NcError.get(this.context).prohibitedSyncTableOperation({
|
||||
modelName: this.model.title,
|
||||
@@ -5550,7 +5607,12 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleHooks(hookName, prevData, newData, req): Promise<void> {
|
||||
protected async handleHooks(
|
||||
hookName: string,
|
||||
prevData: Record<string, any> | Record<string, any>[] | null,
|
||||
newData: Record<string, any> | Record<string, any>[] | null,
|
||||
req: NcRequest,
|
||||
): Promise<void> {
|
||||
Noco.eventEmitter.emit(HANDLE_WEBHOOK, {
|
||||
context: { ...this.context, cache: false, cacheMap: undefined },
|
||||
hookName,
|
||||
@@ -5563,16 +5625,31 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
});
|
||||
}
|
||||
|
||||
public async errorInsert(_e, _data, _trx, _cookie) {}
|
||||
public async errorInsert(
|
||||
_e: Error,
|
||||
_data: Record<string, any>,
|
||||
_trx: any,
|
||||
_cookie: NcRequest,
|
||||
) {}
|
||||
|
||||
public async errorUpdate(_e, _data, _trx, _cookie) {}
|
||||
public async errorUpdate(
|
||||
_e: Error,
|
||||
_data: Record<string, any>,
|
||||
_trx: any,
|
||||
_cookie: NcRequest,
|
||||
) {}
|
||||
|
||||
// todo: handle composite primary key
|
||||
public extractPksValues(data: any, asString = false) {
|
||||
public extractPksValues(data: Record<string, any>, asString = false) {
|
||||
return dataWrapper(data).extractPksValue(this.model, asString);
|
||||
}
|
||||
|
||||
protected async errorDelete(_e, _id, _trx, _cookie) {}
|
||||
protected async errorDelete(
|
||||
_e: Error,
|
||||
_id: Record<string, any>,
|
||||
_trx: any,
|
||||
_cookie: NcRequest,
|
||||
) {}
|
||||
|
||||
async validate(
|
||||
data: Record<string, any>,
|
||||
@@ -6342,7 +6419,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}[]
|
||||
> {
|
||||
try {
|
||||
const { where, ...rest } = this._getListArgs(args as any);
|
||||
const { where, ...rest } = this._getListArgs(args);
|
||||
const columns = await this.model.getColumns(this.context);
|
||||
const column = columns?.find((col) => col.id === args.groupColumnId);
|
||||
|
||||
@@ -6638,7 +6715,10 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
return await this.execAndParse(qb);
|
||||
}
|
||||
|
||||
public async execAndGetRows(query: string, trx?: Knex | CustomKnex) {
|
||||
public async execAndGetRows(
|
||||
query: string,
|
||||
trx?: Knex | CustomKnex,
|
||||
): Promise<Record<string, any>[]> {
|
||||
trx = trx || this.dbDriver;
|
||||
|
||||
query = this.sanitizeQuery(query);
|
||||
@@ -6650,7 +6730,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
} else if (this.isMySQL && INSERT_REGEX.test(query)) {
|
||||
const res = await trx.raw(query);
|
||||
if (res?.[0] && res[0].insertId !== undefined) {
|
||||
return res[0].insertId;
|
||||
return [{ insertId: res[0].insertId }];
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
@@ -6658,20 +6738,20 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
}
|
||||
|
||||
public async execAndParse(
|
||||
qb: Knex.QueryBuilder | string,
|
||||
dependencyColumns: Column[] | undefined | null,
|
||||
options: ExecAndParseOptions & { first: true },
|
||||
): Promise<Record<string, any>>;
|
||||
public async execAndParse(
|
||||
qb: Knex.QueryBuilder | string,
|
||||
dependencyColumns?: Column[] | null,
|
||||
options?: ExecAndParseOptions,
|
||||
): Promise<Record<string, any>[]>;
|
||||
public async execAndParse(
|
||||
qb: Knex.QueryBuilder | string,
|
||||
dependencyColumns?: Column[],
|
||||
options: {
|
||||
skipDateConversion?: boolean;
|
||||
skipAttachmentConversion?: boolean;
|
||||
skipSubstitutingColumnIds?: boolean;
|
||||
skipUserConversion?: boolean;
|
||||
skipJsonConversion?: boolean;
|
||||
raw?: boolean; // alias for skipDateConversion and skipAttachmentConversion
|
||||
first?: boolean;
|
||||
bulkAggregate?: boolean;
|
||||
apiVersion?: NcApiVersion;
|
||||
} = {
|
||||
options: ExecAndParseOptions = {
|
||||
skipDateConversion: false,
|
||||
skipAttachmentConversion: false,
|
||||
skipSubstitutingColumnIds: false,
|
||||
@@ -6802,7 +6882,10 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
return data;
|
||||
}
|
||||
|
||||
sanitizeQuery(query: string | string[]) {
|
||||
sanitizeQuery(query: string): string;
|
||||
sanitizeQuery(query: string[]): string[];
|
||||
sanitizeQuery(query: string | string[]): string | string[];
|
||||
sanitizeQuery(query: string | string[]): string | string[] {
|
||||
const fn = (q: string) => {
|
||||
if (!this.isPg && !this.isSnowflake) {
|
||||
return unsanitize(q);
|
||||
@@ -6974,6 +7057,16 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
});
|
||||
}
|
||||
|
||||
protected async convertUserFormat(
|
||||
data: Record<string, any>[],
|
||||
dependencyColumns?: Column[],
|
||||
apiVersion?: NcApiVersion,
|
||||
): Promise<Record<string, any>[]>;
|
||||
protected async convertUserFormat(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
apiVersion?: NcApiVersion,
|
||||
): Promise<Record<string, any>>;
|
||||
protected async convertUserFormat(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
@@ -7350,6 +7443,14 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
);
|
||||
}
|
||||
|
||||
public async convertJsonTypes(
|
||||
data: Record<string, any>[],
|
||||
dependencyColumns?: Column[],
|
||||
): Promise<Record<string, any>[]>;
|
||||
public async convertJsonTypes(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
): Promise<Record<string, any>>;
|
||||
public async convertJsonTypes(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
@@ -7412,6 +7513,14 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
}
|
||||
|
||||
public async convertMultiSelectTypes(
|
||||
data: Record<string, any>[],
|
||||
dependencyColumns?: Column[],
|
||||
): Promise<Record<string, any>[]>;
|
||||
public async convertMultiSelectTypes(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
): Promise<Record<string, any>>;
|
||||
public async convertMultiSelectTypes(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
@@ -7438,6 +7547,14 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
}
|
||||
}
|
||||
|
||||
public async convertAttachmentType(
|
||||
data: Record<string, any>[],
|
||||
dependencyColumns?: Column[],
|
||||
): Promise<Record<string, any>[]>;
|
||||
public async convertAttachmentType(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
): Promise<Record<string, any>>;
|
||||
public async convertAttachmentType(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
@@ -7648,6 +7765,14 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
return d;
|
||||
}
|
||||
|
||||
public convertDateFormat(
|
||||
data: Record<string, any>[],
|
||||
dependencyColumns?: Column[],
|
||||
): Record<string, any>[];
|
||||
public convertDateFormat(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
): Record<string, any>;
|
||||
public convertDateFormat(
|
||||
data: Record<string, any>,
|
||||
dependencyColumns?: Column[],
|
||||
@@ -7771,7 +7896,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
try {
|
||||
await this.model.getColumns(this.context);
|
||||
|
||||
const { where, sort } = this._getListArgs(args as any);
|
||||
const { where, sort } = this._getListArgs(args);
|
||||
// todo: get only required fields
|
||||
|
||||
const relColumn = this.model.columnsById[colId];
|
||||
|
||||
@@ -95,8 +95,9 @@ export const baseModelInsert = (baseModel: IBaseModelSqlV2) => {
|
||||
} else {
|
||||
const res = await baseModel.execAndParse(query, null, {
|
||||
raw: true,
|
||||
first: true,
|
||||
});
|
||||
id = res?.id ?? res[0]?.insertId ?? res;
|
||||
id = res?.id ?? res?.insertId ?? res;
|
||||
}
|
||||
|
||||
if (ai) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const LTARColsUpdater = (param: {
|
||||
if (!(col.title in d)) continue;
|
||||
|
||||
// extract existing link values to current record
|
||||
let existingLinks = [];
|
||||
let existingLinks: Record<string, any>[] | Record<string, any> = [];
|
||||
|
||||
profiler.log(`${col.colOptions.type} list start`);
|
||||
if (isMMOrMMLike(col)) {
|
||||
|
||||
@@ -514,7 +514,7 @@ export const selectObject = (baseModel: IBaseModelSqlV2, logger: Logger) => {
|
||||
break;
|
||||
}
|
||||
case UITypes.LongText: {
|
||||
if ((baseModel.dbDriver as any).isExternal) {
|
||||
if (baseModel.dbDriver.isExternal) {
|
||||
const colPath = sanitize(
|
||||
`${alias || baseModel.tnPath}.${column.column_name}`,
|
||||
);
|
||||
|
||||
@@ -1046,12 +1046,23 @@ function parseNestedCondition(obj, qb, pKey?, table?, tableAlias?) {
|
||||
return qb;
|
||||
}
|
||||
|
||||
interface ExtDbConfig {
|
||||
dbMux?: string;
|
||||
sourceId?: string;
|
||||
client?: string;
|
||||
upgrader?: boolean;
|
||||
source?: { upgraderQueries?: string[] };
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type CustomKnex = Knex & {
|
||||
attachToTransaction?: (fn: () => void) => void;
|
||||
ops?: (() => void)[];
|
||||
_cteGenerator?: CTEGenerator;
|
||||
cteGenerator?: (context?: NcContext) => CTEGenerator;
|
||||
applyCte?: (qb: Knex.QueryInterface) => void;
|
||||
extDb?: ExtDbConfig;
|
||||
isExternal?: boolean;
|
||||
};
|
||||
|
||||
type CustomTransaction = Knex.Transaction & {
|
||||
@@ -1063,7 +1074,7 @@ type CustomTransaction = Knex.Transaction & {
|
||||
|
||||
function CustomKnex(
|
||||
arg: string | Knex.Config<any> | any,
|
||||
extDb?: any,
|
||||
extDb?: ExtDbConfig,
|
||||
): CustomKnex {
|
||||
if (arg?.client === 'sqlite3') {
|
||||
arg.useNullAsDefault = true;
|
||||
@@ -1604,3 +1615,4 @@ const parseConditionv2 = (_obj: Filter | FilterType, qb: Knex.QueryBuilder) => {
|
||||
|
||||
export default CustomKnex;
|
||||
export { Knex, CustomKnex as XKnex };
|
||||
export type { ExtDbConfig };
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
NestedLinkAuditEntry,
|
||||
NestedLinkLastModifiedEntry,
|
||||
} from '~/db/BaseModelSqlv2/nested-link-preparator';
|
||||
import type { ExecAndParseOptions } from 'src/db/BaseModelSqlv2';
|
||||
|
||||
export interface IBaseModelSqlV2 {
|
||||
context: NcContext;
|
||||
@@ -43,19 +44,14 @@ export interface IBaseModelSqlV2 {
|
||||
): Promise<any>;
|
||||
execAndParse(
|
||||
qb: Knex.QueryBuilder | string,
|
||||
dependencyColumns?: Column[],
|
||||
options?: {
|
||||
skipDateConversion?: boolean;
|
||||
skipAttachmentConversion?: boolean;
|
||||
skipSubstitutingColumnIds?: boolean;
|
||||
skipUserConversion?: boolean;
|
||||
skipJsonConversion?: boolean;
|
||||
raw?: boolean; // alias for skipDateConversion and skipAttachmentConversion
|
||||
first?: boolean;
|
||||
bulkAggregate?: boolean;
|
||||
apiVersion?: NcApiVersion;
|
||||
},
|
||||
): Promise<any>;
|
||||
dependencyColumns: Column[] | undefined | null,
|
||||
options: ExecAndParseOptions & { first: true },
|
||||
): Promise<Record<string, any>>;
|
||||
execAndParse(
|
||||
qb: Knex.QueryBuilder | string,
|
||||
dependencyColumns?: Column[] | null,
|
||||
options?: ExecAndParseOptions,
|
||||
): Promise<Record<string, any>[]>;
|
||||
|
||||
prepareNocoData(
|
||||
data,
|
||||
|
||||
@@ -37,7 +37,7 @@ export class FormulaGeneralHandler extends ComputedFieldHandler {
|
||||
).builder;
|
||||
const parsedTree: ParsedFormulaNode = formula.getParsedTree();
|
||||
const value =
|
||||
parsedTree.dataType === FormulaDataTypes.DATE
|
||||
parsedTree?.dataType === FormulaDataTypes.DATE
|
||||
? filter.value
|
||||
: knex.raw('?', [
|
||||
// convert value to number if formulaDataType if numeric
|
||||
|
||||
@@ -53,7 +53,9 @@ export class LookupGeneralHandler extends ComputedFieldHandler {
|
||||
|
||||
const colOptions = await column.getColOptions<LookupColumn>(context);
|
||||
|
||||
if (colOptions?.error) {
|
||||
// Skip filter when colOptions is missing (orphaned lookup) or carries
|
||||
// a parse error — emitting an empty clause keeps the query valid.
|
||||
if (!colOptions || colOptions.error) {
|
||||
return {
|
||||
clause: (_qb) => {},
|
||||
rootApply: undefined,
|
||||
|
||||
@@ -525,10 +525,10 @@ export default async function formulaQueryBuilderv2({
|
||||
TelemetryHandlerService.sendPriorityError(context, {
|
||||
trigger: 'formulaQueryBuilder',
|
||||
error_type: 'FORMULA_TOO_LONG_ERROR',
|
||||
message: `Formula length too long for ${columnInfo.title}${columnInfo.id}`,
|
||||
message: `Generated query too long for ${columnInfo.title}${columnInfo.id}`,
|
||||
});
|
||||
NcError.get(context).formulaError(
|
||||
`Formula length too long for ${columnInfo.title}`,
|
||||
`The generated query for ${columnInfo.title} exceeds the maximum allowed length. Try simplifying the formula by reducing the number of referenced fields, lookup chains, or nested formula references.`,
|
||||
);
|
||||
}
|
||||
if (!validateFormula) return qb;
|
||||
|
||||
@@ -493,7 +493,7 @@ export class RelationManager {
|
||||
// Wrap cardinality enforcement + insert in a single transaction.
|
||||
// External mux sources don't support PG transactions over HTTP — skip
|
||||
// the transaction wrapper but still execute queries through execAndParse.
|
||||
const isExternal = (baseModel.dbDriver as any).isExternal;
|
||||
const isExternal = !!baseModel.dbDriver.isExternal;
|
||||
const trx: any = isExternal
|
||||
? baseModel.dbDriver
|
||||
: await baseModel.dbDriver.transaction();
|
||||
|
||||
@@ -1251,12 +1251,17 @@ abstract class BaseModel {
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
sort = '',
|
||||
}: { limit?: number | string; offset?: number | string; sort?: string },
|
||||
}: {
|
||||
limit?: number | string;
|
||||
offset?: number | string;
|
||||
sort?: string | string[];
|
||||
},
|
||||
) {
|
||||
query.offset(offset).limit(limit);
|
||||
|
||||
if (sort) {
|
||||
sort.split(',').forEach((o) => {
|
||||
const sortItems = Array.isArray(sort) ? sort : sort.split(',');
|
||||
sortItems.forEach((o) => {
|
||||
if (o[0] === '-') {
|
||||
query.orderBy(o.slice(1), 'desc');
|
||||
} else {
|
||||
@@ -1521,7 +1526,7 @@ export interface XcFilter {
|
||||
limit?: string | number;
|
||||
shuffle?: string | number;
|
||||
offset?: string | number;
|
||||
sort?: string;
|
||||
sort?: string | string[];
|
||||
fields?: string;
|
||||
filterArr?: Filter[];
|
||||
sortArr?: Sort[];
|
||||
@@ -1542,6 +1547,8 @@ export interface XcFilterWithAlias extends XcFilter {
|
||||
s?: string;
|
||||
f?: string;
|
||||
p?: string | number;
|
||||
/** Allow extra properties — callers pass custom fields that _getListArgs ignores. */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default BaseModel;
|
||||
@@ -69,7 +69,7 @@ export abstract class GenericDBQueryClient implements DBQueryClient {
|
||||
async singleQueryRead(
|
||||
_context: any,
|
||||
_ctx: any,
|
||||
): Promise<PagedResponseImpl<Record<string, any>>> {
|
||||
): Promise<Record<string, any>> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { OperationName } from '~/command-registry/op-names';
|
||||
|
||||
// CE no-op stub. EE overrides with the real implementation.
|
||||
export function TraceCommand(_name: OperationName, _version: number = 1) {
|
||||
return function (
|
||||
_target: unknown,
|
||||
_propertyKey: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
) {
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Catch, Logger, NotFoundException } from '@nestjs/common';
|
||||
import * as Sentry from '@sentry/nestjs';
|
||||
|
||||
import { ThrottlerException } from '@nestjs/throttler';
|
||||
import hash from 'object-hash';
|
||||
import {
|
||||
NcApiVersion,
|
||||
NcErrorType,
|
||||
@@ -12,8 +11,7 @@ import {
|
||||
} from 'nocodb-sdk';
|
||||
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import NocoCache from '~/cache/NocoCache';
|
||||
import { CacheGetType } from '~/utils/globals';
|
||||
import { throttlerLogger } from '~/helpers/throttlerLogger';
|
||||
import {
|
||||
AjvError,
|
||||
BadRequest,
|
||||
@@ -90,64 +88,10 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
this.logError(exception, request);
|
||||
|
||||
if (exception instanceof ThrottlerException) {
|
||||
const key = hash({
|
||||
ip: request.ip,
|
||||
baseId: (request as any).ncBaseId,
|
||||
throttlerLogger.record({
|
||||
workspaceId: (request as any).ncWorkspaceId,
|
||||
path: request.path,
|
||||
baseId: (request as any).ncBaseId,
|
||||
});
|
||||
|
||||
const cacheKey = `throttler:${key}`;
|
||||
|
||||
NocoCache.get('root', cacheKey, CacheGetType.TYPE_OBJECT)
|
||||
.then((data) => {
|
||||
if (!data) {
|
||||
this.logger.warn(
|
||||
`ThrottlerException: ${exception.message}, Path: ${
|
||||
request.path
|
||||
}, Workspace ID: ${(request as any).ncWorkspaceId}, Base ID: ${
|
||||
(request as any).ncBaseId
|
||||
}`,
|
||||
);
|
||||
|
||||
NocoCache.setExpiring(
|
||||
'root',
|
||||
cacheKey,
|
||||
{ value: true, count: 1, timestamp: Date.now() },
|
||||
300,
|
||||
).catch((err) => {
|
||||
this.logger.error(err);
|
||||
});
|
||||
} else {
|
||||
data.count += 1;
|
||||
|
||||
const ttlInSeconds = Math.floor(
|
||||
(data.timestamp + 300000 - Date.now()) / 1000,
|
||||
);
|
||||
|
||||
if (ttlInSeconds > 0) {
|
||||
NocoCache.setExpiring('root', cacheKey, data, ttlInSeconds).catch(
|
||||
(err) => {
|
||||
this.logger.error(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// log every 50th request in last 5 minutes after first
|
||||
if (data.count % 50 === 0) {
|
||||
this.logger.warn(
|
||||
`ThrottlerException: ${exception.message}, Path: ${
|
||||
request.path
|
||||
}, Workspace ID: ${(request as any).ncWorkspaceId}, Base ID: ${
|
||||
(request as any).ncBaseId
|
||||
}, Requests in last 5 minutes: ${data.count}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// if sso error then redirect to ui with error in query parameter
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { NcContext } from '~/interface/config';
|
||||
import type { RollupColumn, View } from '~/models';
|
||||
import type { ColumnWebhookManager } from '~/utils/column-webhook-manager';
|
||||
import type Model from '~/models/Model';
|
||||
import type { LtarHmBtIds } from '~/services/columns.service.type';
|
||||
import { GridViewColumn } from '~/models';
|
||||
import validateParams from '~/helpers/validateParams';
|
||||
import { getUniqueColumnAliasName } from '~/helpers/getUniqueName';
|
||||
@@ -55,6 +56,10 @@ export async function createHmAndBtColumn(
|
||||
parentColumn?: Column,
|
||||
isCustom = false,
|
||||
columnWebhookManager?: ColumnWebhookManager,
|
||||
// Sandbox-replay only — pre-set IDs honored by `Column.insert` and ID
|
||||
// capture for the recording side. CE callers leave both undefined.
|
||||
idHints?: LtarHmBtIds,
|
||||
out?: LtarHmBtIds,
|
||||
) {
|
||||
let savedColumn: Column;
|
||||
let crossBaseProps: {
|
||||
@@ -80,6 +85,7 @@ export async function createHmAndBtColumn(
|
||||
const childRelCol = await Column.insert<LinkToAnotherRecordColumn>(
|
||||
{ ...context, base_id: child.base_id },
|
||||
{
|
||||
...(idHints?.childRelColId ? { id: idHints.childRelColId } : {}),
|
||||
title,
|
||||
|
||||
fk_model_id: child.id,
|
||||
@@ -115,6 +121,7 @@ export async function createHmAndBtColumn(
|
||||
},
|
||||
},
|
||||
);
|
||||
if (out) out.childRelColId = childRelCol.id;
|
||||
await columnWebhookManager?.addNewColumnById({
|
||||
columnId: childRelCol.id,
|
||||
action: WebhookActions.INSERT,
|
||||
@@ -157,6 +164,7 @@ export async function createHmAndBtColumn(
|
||||
savedColumn = await Column.insert(
|
||||
{ ...context, base_id: parent.base_id },
|
||||
{
|
||||
...(idHints?.savedColumnId ? { id: idHints.savedColumnId } : {}),
|
||||
title,
|
||||
fk_model_id: parent.id,
|
||||
uidt: isLinks ? UITypes.Links : UITypes.LinkToAnotherRecord,
|
||||
@@ -179,6 +187,7 @@ export async function createHmAndBtColumn(
|
||||
...crossBaseProps,
|
||||
},
|
||||
);
|
||||
if (out) out.savedColumnId = savedColumn.id;
|
||||
await columnWebhookManager?.addNewColumnById({
|
||||
columnId: savedColumn.id,
|
||||
action: WebhookActions.INSERT,
|
||||
@@ -231,6 +240,9 @@ export async function createOOColumn(
|
||||
parentColumn?: Column,
|
||||
isCustom = false,
|
||||
columnWebhookManager?: ColumnWebhookManager,
|
||||
// Sandbox-replay only — see `createHmAndBtColumn` for shape and rationale.
|
||||
idHints?: LtarHmBtIds,
|
||||
out?: LtarHmBtIds,
|
||||
) {
|
||||
let savedColumn: Column;
|
||||
|
||||
@@ -260,6 +272,7 @@ export async function createOOColumn(
|
||||
const childRelCol = await Column.insert<LinkToAnotherRecordColumn>(
|
||||
childContext,
|
||||
{
|
||||
...(idHints?.childRelColId ? { id: idHints.childRelColId } : {}),
|
||||
title,
|
||||
fk_model_id: child.id,
|
||||
// ref_db_alias
|
||||
@@ -291,6 +304,7 @@ export async function createOOColumn(
|
||||
...crossBaseProps,
|
||||
},
|
||||
);
|
||||
if (out) out.childRelColId = childRelCol.id;
|
||||
|
||||
await columnWebhookManager?.addNewColumnById({
|
||||
columnId: childRelCol.id,
|
||||
@@ -337,6 +351,7 @@ export async function createOOColumn(
|
||||
};
|
||||
|
||||
savedColumn = await Column.insert(parentContext, {
|
||||
...(idHints?.savedColumnId ? { id: idHints.savedColumnId } : {}),
|
||||
title,
|
||||
fk_model_id: parent.id,
|
||||
uidt: UITypes.LinkToAnotherRecord,
|
||||
@@ -358,6 +373,7 @@ export async function createOOColumn(
|
||||
...crossBaseProps,
|
||||
...(colExtra || {}),
|
||||
});
|
||||
if (out) out.savedColumnId = savedColumn.id;
|
||||
|
||||
await columnWebhookManager?.addNewColumnById({
|
||||
columnId: savedColumn.id,
|
||||
@@ -777,7 +793,14 @@ export const TableSystemColumns = (isMetaColSupport = false, isMeta = true) => [
|
||||
: []),
|
||||
];
|
||||
|
||||
export const deleteColumnSystemPropsFromRequest = (col: any) => {
|
||||
export enum OperationSource {
|
||||
AT_IMPORT = 'at_import',
|
||||
}
|
||||
|
||||
export const deleteColumnSystemPropsFromRequest = (
|
||||
col: any,
|
||||
opts?: { operationSource?: OperationSource },
|
||||
) => {
|
||||
// remove all properties not in documentations
|
||||
delete col.dt;
|
||||
delete col.np;
|
||||
@@ -795,7 +818,20 @@ export const deleteColumnSystemPropsFromRequest = (col: any) => {
|
||||
// delete col.dtxs;
|
||||
delete col.au;
|
||||
delete col.validate;
|
||||
delete col.system;
|
||||
switch (opts?.operationSource) {
|
||||
case OperationSource.AT_IMPORT: {
|
||||
const isNcRecordColumn = col.system &&
|
||||
['ncRecordId', 'ncRecordHash'].includes(col.title);
|
||||
if (!isNcRecordColumn) {
|
||||
delete col.system;
|
||||
}
|
||||
break;
|
||||
}
|
||||
deafult: {
|
||||
delete col.system;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// get the reverse type of the relation
|
||||
|
||||
@@ -99,6 +99,12 @@ export function _wherePk(
|
||||
ids = (id + '').split('___').map((val) => val.replaceAll('\\_', '_'));
|
||||
}
|
||||
|
||||
// Reject incomplete composite ids up-front — otherwise knex builds a
|
||||
// WHERE with `undefined` bindings and throws a generic 500.
|
||||
if (!skipPkValidation && (ids as unknown[]).length < primaryKeys.length) {
|
||||
NcError.invalidPrimaryKey(id, primaryKeys.map((pk) => pk.title).join(','));
|
||||
}
|
||||
|
||||
for (let i = 0; i < primaryKeys.length; ++i) {
|
||||
if (primaryKeys[i].dt === 'bytea') {
|
||||
// if column is bytea, then we need to encode the id to hex based on format
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NcContext } from '~/interface/config';
|
||||
|
||||
// CE no-op stubs. Sandboxes are an EE feature; EE overrides these with
|
||||
// the real impl in `src/ee/helpers/sandboxGuards.ts`.
|
||||
|
||||
export async function assertNotSandbox(
|
||||
_context: NcContext,
|
||||
_message?: string,
|
||||
): Promise<void> {}
|
||||
|
||||
export async function assertNotSandboxProduction(
|
||||
_context: NcContext,
|
||||
_message?: string,
|
||||
): Promise<void> {}
|
||||
|
||||
export async function assertNotLockedViewOnSandboxProduction(
|
||||
_context: NcContext,
|
||||
_viewId: string,
|
||||
_message?: string,
|
||||
): Promise<void> {}
|
||||
@@ -14,6 +14,7 @@ import type { User } from '~/models';
|
||||
import { Permission } from '~/models';
|
||||
import {
|
||||
deleteColumnSystemPropsFromRequest,
|
||||
type OperationSource,
|
||||
TableSystemColumns,
|
||||
} from '~/helpers/columnHelpers';
|
||||
import {
|
||||
@@ -29,10 +30,12 @@ export const repopulateCreateTableSystemColumns = (
|
||||
columns,
|
||||
clientType,
|
||||
isMeta = true,
|
||||
operationSource,
|
||||
}: {
|
||||
columns: (ColumnType & { cn?: string })[];
|
||||
clientType: DriverClient;
|
||||
isMeta?: boolean;
|
||||
operationSource?: OperationSource;
|
||||
},
|
||||
) => {
|
||||
const tableSystemColumns = TableSystemColumns(
|
||||
@@ -85,7 +88,7 @@ export const repopulateCreateTableSystemColumns = (
|
||||
col.cn = col.column_name;
|
||||
}
|
||||
}
|
||||
deleteColumnSystemPropsFromRequest(col);
|
||||
deleteColumnSystemPropsFromRequest(col, { operationSource });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import NocoCache from '~/cache/NocoCache';
|
||||
|
||||
const FLUSH_INTERVAL_MS = 60_000;
|
||||
// 1.5x window — absorbs clock skew between pods so a late-arriving leader
|
||||
// can't emit a duplicate after the early leader's flag has expired.
|
||||
const REDIS_DEDUP_TTL_SECONDS = 90;
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
firstSeenMs: number;
|
||||
workspaceId?: string;
|
||||
baseId?: string;
|
||||
}
|
||||
|
||||
export class ThrottlerLogger {
|
||||
private readonly logger = new Logger('ThrottlerLogger');
|
||||
private buckets = new Map<string, Bucket>();
|
||||
|
||||
record({ workspaceId, baseId }: { workspaceId?: string; baseId?: string }) {
|
||||
const key = `${workspaceId ?? 'anon'}:${baseId ?? '-'}`;
|
||||
const existing = this.buckets.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
return;
|
||||
}
|
||||
this.buckets.set(key, {
|
||||
count: 1,
|
||||
firstSeenMs: Date.now(),
|
||||
workspaceId,
|
||||
baseId,
|
||||
});
|
||||
}
|
||||
|
||||
async flush() {
|
||||
if (this.buckets.size === 0) return;
|
||||
|
||||
const snapshot = this.buckets;
|
||||
this.buckets = new Map();
|
||||
|
||||
const windowId = Math.floor(Date.now() / FLUSH_INTERVAL_MS);
|
||||
|
||||
for (const [key, bucket] of snapshot) {
|
||||
const counterKey = `throttler:count:${windowId}:${key}`;
|
||||
const leaderKey = `throttler:leader:${windowId}:${key}`;
|
||||
|
||||
// Best-effort: every pod contributes to the shared counter, only the
|
||||
// leader logs. The logged total reflects whatever pods had flushed
|
||||
// before the leader's INCRBY — i.e. an approximate global count, not
|
||||
// exact, but bounded above by the true total.
|
||||
let total = bucket.count;
|
||||
let isLeader = true;
|
||||
try {
|
||||
total = await NocoCache.incrbyExpiring(
|
||||
'root',
|
||||
counterKey,
|
||||
bucket.count,
|
||||
REDIS_DEDUP_TTL_SECONDS,
|
||||
);
|
||||
isLeader = await NocoCache.setIfNotExist(
|
||||
'root',
|
||||
leaderKey,
|
||||
'1',
|
||||
REDIS_DEDUP_TTL_SECONDS,
|
||||
);
|
||||
} catch {
|
||||
// Cache unavailable — log local count so we don't lose the signal.
|
||||
// Worst case is O(pods) duplicate lines for this window.
|
||||
total = bucket.count;
|
||||
isLeader = true;
|
||||
}
|
||||
|
||||
if (!isLeader) continue;
|
||||
|
||||
this.logger.warn(
|
||||
`ThrottlerException rollup: count=${total}, workspaceId=${
|
||||
bucket.workspaceId ?? 'anon'
|
||||
}, baseId=${bucket.baseId ?? '-'}, windowMs=${FLUSH_INTERVAL_MS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton — guarded against re-evaluation under nest watch /
|
||||
// hot reload to avoid leaking a setInterval per reload.
|
||||
const SINGLETON_KEY = Symbol.for('nocodb.throttlerLogger');
|
||||
const g = globalThis as { [SINGLETON_KEY]?: ThrottlerLogger };
|
||||
|
||||
if (!g[SINGLETON_KEY]) {
|
||||
const instance = new ThrottlerLogger();
|
||||
const fallbackLogger = new Logger('ThrottlerLogger');
|
||||
setInterval(
|
||||
() => instance.flush().catch((err) => fallbackLogger.error(err)),
|
||||
FLUSH_INTERVAL_MS,
|
||||
).unref?.();
|
||||
g[SINGLETON_KEY] = instance;
|
||||
}
|
||||
|
||||
export const throttlerLogger: ThrottlerLogger = g[SINGLETON_KEY]!;
|
||||
@@ -42,7 +42,11 @@ Handlebars.registerHelper('json', function (context, pretty = false) {
|
||||
|
||||
const logger = new Logger('webhookHelpers');
|
||||
|
||||
export function parseBody(template: string, data: any): string {
|
||||
export function parseBody(
|
||||
template: string,
|
||||
data: any,
|
||||
vars?: Record<string, string>,
|
||||
): string {
|
||||
if (!template) {
|
||||
return template;
|
||||
}
|
||||
@@ -51,6 +55,7 @@ export function parseBody(template: string, data: any): string {
|
||||
return Handlebars.compile(template, { noEscape: true })({
|
||||
data,
|
||||
event: data,
|
||||
vars: vars || {},
|
||||
});
|
||||
} catch (e) {
|
||||
// if parsing fails then return the original template
|
||||
|
||||
@@ -77,6 +77,8 @@ export enum JobTypes {
|
||||
ChatApproval = 'chat-approval',
|
||||
BaseTrashCleanUp = 'base-trash-clean-up',
|
||||
DataImport = 'data-import',
|
||||
SandboxMerge = 'sandbox-merge',
|
||||
ManagedAppUpdate = 'managed-app-update',
|
||||
}
|
||||
|
||||
export const SKIP_STORING_JOB_META = [
|
||||
@@ -188,6 +190,10 @@ export interface DuplicateBaseJobData extends JobData {
|
||||
excludeScripts?: boolean;
|
||||
excludeDashboards?: boolean;
|
||||
excludeWorkflows?: boolean;
|
||||
excludeDocuments?: boolean;
|
||||
excludePersonalViews?: boolean;
|
||||
excludePermissions?: boolean;
|
||||
excludeRls?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,6 +229,24 @@ export interface DuplicateDashboardJobData extends JobData {
|
||||
options: never;
|
||||
}
|
||||
|
||||
export interface SandboxMergeJobData extends JobData {
|
||||
sandboxBaseId: string;
|
||||
productionBaseId: string;
|
||||
sandboxId: string;
|
||||
req: NcRequest;
|
||||
selectedChangelogIds?: string[];
|
||||
}
|
||||
|
||||
export interface ManagedAppUpdateJobData extends JobData {
|
||||
managedAppId: string;
|
||||
managedAppTitle: string;
|
||||
masterBaseId: string;
|
||||
masterWorkspaceId: string;
|
||||
newVersionId: string;
|
||||
newVersion: string;
|
||||
req: NcRequest;
|
||||
}
|
||||
|
||||
export interface HandleWebhookJobData extends JobData {
|
||||
hookId: string;
|
||||
modelId: string;
|
||||
|
||||
@@ -8,7 +8,8 @@ server.disable('etag');
|
||||
server.disable('x-powered-by');
|
||||
server.use(
|
||||
cors({
|
||||
exposedHeaders: 'xc-db-response',
|
||||
exposedHeaders:
|
||||
'xc-db-response, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Policy, Retry-After',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ export class MetaService {
|
||||
[MetaTable.HOOK_LOGS]: 'hkl',
|
||||
[MetaTable.API_TOKENS]: 'tkn',
|
||||
[MetaTable.EXTENSIONS]: 'ext',
|
||||
[MetaTable.BASE_VARIABLES]: 'bv',
|
||||
[MetaTable.COMMENTS]: 'com',
|
||||
[MetaTable.COMMENTS_REACTIONS]: 'cre',
|
||||
[MetaTable.USER_COMMENTS_NOTIFICATIONS_PREFERENCE]: 'cnp',
|
||||
@@ -143,6 +144,7 @@ export class MetaService {
|
||||
[MetaTable.MANAGED_APP_VERSIONS]: 'mav',
|
||||
[MetaTable.MANAGED_APP_DEPLOYMENT_LOGS]: 'madl',
|
||||
[MetaTable.SANDBOXES]: 'sb',
|
||||
[MetaTable.SANDBOX_CHANGELOG]: 'scl',
|
||||
[MetaTable.SCIM_CONFIG]: 'scfg',
|
||||
[MetaTable.RLS_POLICIES]: 'rlp',
|
||||
[MetaTable.RLS_POLICY_SUBJECTS]: 'rlps',
|
||||
@@ -951,8 +953,12 @@ export class MetaService {
|
||||
? this.connection
|
||||
: await this.connection.transaction();
|
||||
|
||||
// todo: tobe done
|
||||
return new MetaService(
|
||||
// Instantiate via this.constructor so subclasses (e.g. EE MetaService)
|
||||
// returned from startTransaction keep their overridden methods.
|
||||
// Hard-coding `new MetaService(...)` here would always return a CE
|
||||
// instance even when called on an EE/EE-Cloud subclass.
|
||||
const Ctor = this.constructor as typeof MetaService;
|
||||
return new Ctor(
|
||||
this.config,
|
||||
trx,
|
||||
// we need to keep track of the nested transaction level
|
||||
|
||||
@@ -54,6 +54,7 @@ import * as nc_202604160000_docs_in_data from './v0/nc_202604160000_docs_in_data
|
||||
import * as nc_202604200002_trash_cleanup_due_at from './v0/nc_202604200002_trash_cleanup_due_at';
|
||||
import * as nc_202604220000_uuid_readonly from './v0/nc_202604220000_uuid_readonly';
|
||||
import * as nc_202604270000_base_trash from './v0/nc_202604270000_base_trash';
|
||||
import * as nc_202604290000_base_variables_and_sandbox_changelog from './v0/nc_202604290000_base_variables_and_sandbox_changelog';
|
||||
|
||||
// Create a custom migration source class
|
||||
export default class XcMigrationSourcev0 {
|
||||
@@ -119,6 +120,7 @@ export default class XcMigrationSourcev0 {
|
||||
'nc_202603180000_user_mfa',
|
||||
'nc_202604220000_uuid_readonly',
|
||||
'nc_202604270000_base_trash',
|
||||
'nc_202604290000_base_variables_and_sandbox_changelog',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -240,6 +242,8 @@ export default class XcMigrationSourcev0 {
|
||||
return nc_202604220000_uuid_readonly;
|
||||
case 'nc_202604270000_base_trash':
|
||||
return nc_202604270000_base_trash;
|
||||
case 'nc_202604290000_base_variables_and_sandbox_changelog':
|
||||
return nc_202604290000_base_variables_and_sandbox_changelog;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,18 @@ const up = async (knex: Knex) => {
|
||||
const isRoot = !doc.parent_id;
|
||||
const offset = isRoot ? maxOrderByBase.get(doc.base_id) || 0 : 0;
|
||||
|
||||
// nc_docs_v2.title is varchar(512), nc_models_v2.title is varchar(255).
|
||||
// Truncate to fit and preserve the overflow in description so nothing is lost.
|
||||
const title = doc.title?.slice(0, 255);
|
||||
const overflow =
|
||||
doc.title && doc.title.length > 255 ? doc.title.slice(255) : null;
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
base_id: doc.base_id,
|
||||
fk_workspace_id: doc.fk_workspace_id,
|
||||
title: doc.title,
|
||||
title,
|
||||
description: overflow,
|
||||
meta: doc.meta,
|
||||
order: (Number(doc.order) || 0) + offset,
|
||||
parent_id: doc.parent_id,
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { MetaTable } from '~/utils/globals';
|
||||
|
||||
const up = async (knex: Knex) => {
|
||||
await knex.schema.createTable(MetaTable.BASE_VARIABLES, (table) => {
|
||||
table.string('id', 20).notNullable();
|
||||
table.string('base_id', 20);
|
||||
table.string('fk_workspace_id', 20);
|
||||
table.string('key', 255);
|
||||
table.text('value');
|
||||
table.text('description');
|
||||
table.string('inheritance', 20).defaultTo('fixed');
|
||||
table.string('type', 20).defaultTo('text');
|
||||
table.float('order');
|
||||
table.text('default_value');
|
||||
table.boolean('is_overridden').defaultTo(false);
|
||||
table.boolean('is_inherited').defaultTo(false);
|
||||
table.timestamps(true, true);
|
||||
table.primary(['base_id', 'id']);
|
||||
});
|
||||
|
||||
await knex.schema.alterTable(MetaTable.BASE_VARIABLES, (table) => {
|
||||
table.unique(
|
||||
['fk_workspace_id', 'base_id', 'key'],
|
||||
'nc_base_variables_ws_base_key_unique',
|
||||
);
|
||||
table.index(
|
||||
['base_id', 'fk_workspace_id'],
|
||||
'nc_base_variables_base_ws_index',
|
||||
);
|
||||
});
|
||||
|
||||
await knex.schema.createTable(MetaTable.SANDBOX_CHANGELOG, (table) => {
|
||||
table.string('id', 20).notNullable().primary();
|
||||
// App-managed monotonic counter (microseconds + intra-tick increment).
|
||||
// Replay iterates in seq-ASC order. created_at has only second precision
|
||||
// (meta.service.ts now()), so we cannot rely on it alone for ordering.
|
||||
table.bigInteger('seq').notNullable();
|
||||
table.string('fk_sandbox_id', 20).notNullable();
|
||||
table.string('base_id', 20).notNullable();
|
||||
table.string('event', 80).notNullable();
|
||||
table.string('entity_type', 40).notNullable();
|
||||
table.string('entity_id', 20);
|
||||
table.string('entity_title', 255);
|
||||
table.string('parent_entity_id', 20);
|
||||
table.string('parent_entity_title', 255);
|
||||
table.string('created_by', 20).notNullable();
|
||||
table.text('description').nullable();
|
||||
table.text('meta');
|
||||
table.string('status', 20).notNullable().defaultTo('pending');
|
||||
table.timestamp('merged_at');
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
|
||||
await knex.schema.alterTable(MetaTable.SANDBOX_CHANGELOG, (table) => {
|
||||
table.index(['fk_sandbox_id', 'seq'], 'nc_scl_sandbox_seq_index');
|
||||
table.index(['base_id'], 'nc_scl_base_id_index');
|
||||
table.index(['entity_type', 'entity_id'], 'nc_scl_entity_type_id_index');
|
||||
});
|
||||
|
||||
// Re-scope subscriber unique index to include base_id so sandbox and
|
||||
// production can hold parallel subscriber rows for the same (workflow, user)
|
||||
// pair.
|
||||
await knex.schema.alterTable(MetaTable.AUTOMATION_SUBSCRIBERS, (table) => {
|
||||
table.dropUnique(
|
||||
['fk_automation_id', 'fk_user_id'],
|
||||
'nc_automation_subscribers_unique_idx',
|
||||
);
|
||||
});
|
||||
|
||||
await knex.schema.alterTable(MetaTable.AUTOMATION_SUBSCRIBERS, (table) => {
|
||||
table.unique(['base_id', 'fk_automation_id', 'fk_user_id'], {
|
||||
indexName: 'nc_automation_subscribers_unique_idx',
|
||||
});
|
||||
});
|
||||
|
||||
// Sandbox terminology: rename "master" → "production" on sandbox-related
|
||||
// tables. Managed-app `managed_app_master` is a separate concept (template
|
||||
// vs installed instance) and is left unchanged.
|
||||
await knex.schema.alterTable(MetaTable.SANDBOXES, (table) => {
|
||||
table.dropIndex(['master_base_id'], 'nc_sandboxes_v2_master_base_id_idx');
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.SANDBOXES, (table) => {
|
||||
table.renameColumn('master_base_id', 'production_base_id');
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.SANDBOXES, (table) => {
|
||||
table.index(
|
||||
['production_base_id'],
|
||||
'nc_sandboxes_v2_production_base_id_idx',
|
||||
);
|
||||
});
|
||||
|
||||
await knex.schema.alterTable(MetaTable.PROJECT, (table) => {
|
||||
table.dropIndex(['is_sandbox_master'], 'nc_bases_is_sandbox_master_idx');
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.PROJECT, (table) => {
|
||||
table.renameColumn('is_sandbox_master', 'is_sandbox_production');
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.PROJECT, (table) => {
|
||||
table.index(
|
||||
['is_sandbox_production'],
|
||||
'nc_bases_is_sandbox_production_idx',
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const down = async (knex: Knex) => {
|
||||
// Reverse sandbox terminology rename
|
||||
await knex.schema.alterTable(MetaTable.PROJECT, (table) => {
|
||||
table.dropIndex(
|
||||
['is_sandbox_production'],
|
||||
'nc_bases_is_sandbox_production_idx',
|
||||
);
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.PROJECT, (table) => {
|
||||
table.renameColumn('is_sandbox_production', 'is_sandbox_master');
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.PROJECT, (table) => {
|
||||
table.index(['is_sandbox_master'], 'nc_bases_is_sandbox_master_idx');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable(MetaTable.SANDBOXES, (table) => {
|
||||
table.dropIndex(
|
||||
['production_base_id'],
|
||||
'nc_sandboxes_v2_production_base_id_idx',
|
||||
);
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.SANDBOXES, (table) => {
|
||||
table.renameColumn('production_base_id', 'master_base_id');
|
||||
});
|
||||
await knex.schema.alterTable(MetaTable.SANDBOXES, (table) => {
|
||||
table.index(['master_base_id'], 'nc_sandboxes_v2_master_base_id_idx');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable(MetaTable.AUTOMATION_SUBSCRIBERS, (table) => {
|
||||
table.dropUnique(
|
||||
['base_id', 'fk_automation_id', 'fk_user_id'],
|
||||
'nc_automation_subscribers_unique_idx',
|
||||
);
|
||||
});
|
||||
|
||||
await knex.schema.alterTable(MetaTable.AUTOMATION_SUBSCRIBERS, (table) => {
|
||||
table.unique(['fk_automation_id', 'fk_user_id'], {
|
||||
indexName: 'nc_automation_subscribers_unique_idx',
|
||||
});
|
||||
});
|
||||
|
||||
await knex.schema.dropTableIfExists(MetaTable.SANDBOX_CHANGELOG);
|
||||
await knex.schema.dropTableIfExists(MetaTable.BASE_VARIABLES);
|
||||
};
|
||||
|
||||
export { up, down };
|
||||
@@ -65,7 +65,7 @@ export default class Base implements BaseType {
|
||||
managed_app_schema_locked?: boolean; // Computed: whether schema modifications are allowed
|
||||
|
||||
// sandbox props
|
||||
is_sandbox_master?: boolean; // Is this base a master base that has sandbox(es)?
|
||||
is_sandbox_production?: boolean; // Is this base a production base that has sandbox(es) derived from it?
|
||||
is_sandbox?: boolean; // Is this base a sandbox base?
|
||||
|
||||
constructor(base: Partial<Base>) {
|
||||
@@ -99,7 +99,7 @@ export default class Base implements BaseType {
|
||||
'managed_app_id',
|
||||
'managed_app_version_id',
|
||||
'auto_update',
|
||||
'is_sandbox_master',
|
||||
'is_sandbox_production',
|
||||
'is_sandbox',
|
||||
]);
|
||||
|
||||
@@ -482,7 +482,7 @@ export default class Base implements BaseType {
|
||||
'managed_app_id',
|
||||
'managed_app_version_id',
|
||||
'auto_update',
|
||||
'is_sandbox_master',
|
||||
'is_sandbox_production',
|
||||
'is_sandbox',
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { BaseVariableValueType } from 'nocodb-sdk';
|
||||
import type { BaseVariableInheritance, BaseVariableType } from 'nocodb-sdk';
|
||||
import type { NcContext } from '~/interface/config';
|
||||
import Noco from '~/Noco';
|
||||
import { extractProps } from '~/helpers/extractProps';
|
||||
import { NcError } from '~/helpers/ncError';
|
||||
import {
|
||||
CacheDelDirection,
|
||||
CacheGetType,
|
||||
CacheScope,
|
||||
MetaTable,
|
||||
} from '~/utils/globals';
|
||||
import NocoCache from '~/cache/NocoCache';
|
||||
import { getCredentialEncryptSecret } from '~/utils/encryptDecrypt';
|
||||
|
||||
const KEY_REGEX = /^[A-Z][A-Z0-9_]*$/;
|
||||
const MAX_VALUE_LENGTH = 65536; // 64KB
|
||||
|
||||
export default class BaseVariable implements BaseVariableType {
|
||||
id?: string;
|
||||
base_id?: string;
|
||||
fk_workspace_id?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
inheritance?: BaseVariableInheritance;
|
||||
type?: BaseVariableValueType;
|
||||
order?: number;
|
||||
default_value?: string;
|
||||
is_overridden?: boolean;
|
||||
is_inherited?: boolean;
|
||||
|
||||
constructor(data: Partial<BaseVariable>) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
get isSecret(): boolean {
|
||||
return this.type === BaseVariableValueType.SECRET;
|
||||
}
|
||||
|
||||
private static isSecretType(data: Record<string, any>): boolean {
|
||||
return data.type === BaseVariableValueType.SECRET;
|
||||
}
|
||||
|
||||
private static encryptValue(value: string): string {
|
||||
const secret = getCredentialEncryptSecret();
|
||||
if (!secret || !value) return value;
|
||||
return CryptoJS.AES.encrypt(value, secret).toString();
|
||||
}
|
||||
|
||||
private static decryptValue(value: string): string {
|
||||
const secret = getCredentialEncryptSecret();
|
||||
if (!secret || !value) return value;
|
||||
try {
|
||||
return CryptoJS.AES.decrypt(value, secret).toString(CryptoJS.enc.Utf8);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static prepareForDb(
|
||||
data: Partial<BaseVariable>,
|
||||
): Record<string, any> {
|
||||
const obj = { ...data };
|
||||
if (BaseVariable.isSecretType(obj)) {
|
||||
if (obj.value) {
|
||||
obj.value = BaseVariable.encryptValue(obj.value);
|
||||
}
|
||||
if (obj.default_value) {
|
||||
obj.default_value = BaseVariable.encryptValue(obj.default_value);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static prepareForRead(
|
||||
data: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (BaseVariable.isSecretType(data)) {
|
||||
if (data.value) {
|
||||
data.value = BaseVariable.decryptValue(data.value);
|
||||
}
|
||||
if (data.default_value) {
|
||||
data.default_value = BaseVariable.decryptValue(data.default_value);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static async get(
|
||||
context: NcContext,
|
||||
variableId: string,
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
let data = await NocoCache.get(
|
||||
context,
|
||||
`${CacheScope.BASE_VARIABLE}:${variableId}`,
|
||||
CacheGetType.TYPE_OBJECT,
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
data = await ncMeta.metaGet2(
|
||||
context.workspace_id,
|
||||
context.base_id,
|
||||
MetaTable.BASE_VARIABLES,
|
||||
variableId,
|
||||
);
|
||||
|
||||
if (data) {
|
||||
NocoCache.set(
|
||||
context,
|
||||
`${CacheScope.BASE_VARIABLE}:${variableId}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
data = BaseVariable.prepareForRead(data);
|
||||
}
|
||||
|
||||
return data && new BaseVariable(data);
|
||||
}
|
||||
|
||||
public static async list(
|
||||
context: NcContext,
|
||||
baseId: string,
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
const cachedList = await NocoCache.getList(
|
||||
context,
|
||||
CacheScope.BASE_VARIABLE,
|
||||
[baseId],
|
||||
);
|
||||
let { list } = cachedList;
|
||||
const { isNoneList } = cachedList;
|
||||
|
||||
if (!isNoneList && !list.length) {
|
||||
list = await ncMeta.metaList2(
|
||||
context.workspace_id,
|
||||
context.base_id,
|
||||
MetaTable.BASE_VARIABLES,
|
||||
{
|
||||
condition: { base_id: baseId },
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
);
|
||||
|
||||
if (list) {
|
||||
await NocoCache.setList(
|
||||
context,
|
||||
CacheScope.BASE_VARIABLE,
|
||||
[baseId],
|
||||
list,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (list || [])
|
||||
.sort((a, b) => (a?.order ?? Infinity) - (b?.order ?? Infinity))
|
||||
.map((item) => new BaseVariable(BaseVariable.prepareForRead(item)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a flat key→value map for webhook template resolution.
|
||||
* Secret values are decrypted. Empty values are skipped.
|
||||
*/
|
||||
public static async listAsMap(
|
||||
context: NcContext,
|
||||
baseId: string,
|
||||
ncMeta = Noco.ncMeta,
|
||||
): Promise<Record<string, string>> {
|
||||
const variables = await BaseVariable.list(context, baseId, ncMeta);
|
||||
const map: Record<string, string> = {};
|
||||
for (const v of variables) {
|
||||
if (v.key && v.value) {
|
||||
map[v.key] = v.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
context: NcContext,
|
||||
data: Partial<BaseVariable>,
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
const insertObj = extractProps(data, [
|
||||
'id',
|
||||
'base_id',
|
||||
'key',
|
||||
'value',
|
||||
'description',
|
||||
'inheritance',
|
||||
'type',
|
||||
'order',
|
||||
'default_value',
|
||||
'is_overridden',
|
||||
'is_inherited',
|
||||
]);
|
||||
|
||||
// Validate key format
|
||||
if (!insertObj.key || !KEY_REGEX.test(insertObj.key)) {
|
||||
NcError.badRequest(
|
||||
'Variable key must be UPPER_SNAKE_CASE (e.g., MY_VARIABLE)',
|
||||
);
|
||||
}
|
||||
|
||||
if (insertObj.value && insertObj.value.length > MAX_VALUE_LENGTH) {
|
||||
NcError.badRequest('Variable value exceeds 64KB limit');
|
||||
}
|
||||
|
||||
if (insertObj.order === null || insertObj.order === undefined) {
|
||||
insertObj.order = await ncMeta.metaGetNextOrder(
|
||||
MetaTable.BASE_VARIABLES,
|
||||
{ base_id: insertObj.base_id },
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await ncMeta.metaInsert2(
|
||||
context.workspace_id,
|
||||
context.base_id,
|
||||
MetaTable.BASE_VARIABLES,
|
||||
BaseVariable.prepareForDb(insertObj),
|
||||
);
|
||||
|
||||
return this.get(context, id, ncMeta).then(async (res) => {
|
||||
await NocoCache.appendToList(
|
||||
context,
|
||||
CacheScope.BASE_VARIABLE,
|
||||
[data.base_id],
|
||||
`${CacheScope.BASE_VARIABLE}:${id}`,
|
||||
);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
public static async update(
|
||||
context: NcContext,
|
||||
variableId: string,
|
||||
data: Partial<BaseVariable>,
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
const updateObj = extractProps(data, [
|
||||
'value',
|
||||
'description',
|
||||
'inheritance',
|
||||
'type',
|
||||
'order',
|
||||
'default_value',
|
||||
'is_overridden',
|
||||
'is_inherited',
|
||||
]);
|
||||
|
||||
if (updateObj.value && updateObj.value.length > MAX_VALUE_LENGTH) {
|
||||
NcError.badRequest('Variable value exceeds 64KB limit');
|
||||
}
|
||||
|
||||
// Resolve effective type after the patch and the existing row so we can
|
||||
// (a) encrypt new value/default_value when SECRET, and (b) re-encrypt or
|
||||
// decrypt the persisted row when type flips between PLAIN and SECRET.
|
||||
const current = await BaseVariable.get(context, variableId, ncMeta);
|
||||
const previousType = current?.type;
|
||||
const nextType = updateObj.type ?? previousType;
|
||||
const willBeSecret = nextType === BaseVariableValueType.SECRET;
|
||||
const typeFlipped = !!previousType && previousType !== nextType;
|
||||
|
||||
// BaseVariable.get() returns decrypted values via prepareForRead, so when
|
||||
// type flips and a field is not in the patch we pull it from `current`
|
||||
// (already plaintext) and write it through under the new encryption state.
|
||||
if (typeFlipped) {
|
||||
if (updateObj.value === undefined && current?.value) {
|
||||
updateObj.value = current.value;
|
||||
}
|
||||
if (updateObj.default_value === undefined && current?.default_value) {
|
||||
updateObj.default_value = current.default_value;
|
||||
}
|
||||
}
|
||||
|
||||
if (willBeSecret) {
|
||||
if (updateObj.value) {
|
||||
updateObj.value = BaseVariable.encryptValue(updateObj.value);
|
||||
}
|
||||
if (updateObj.default_value) {
|
||||
updateObj.default_value = BaseVariable.encryptValue(
|
||||
updateObj.default_value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await ncMeta.metaUpdate(
|
||||
context.workspace_id,
|
||||
context.base_id,
|
||||
MetaTable.BASE_VARIABLES,
|
||||
updateObj,
|
||||
variableId,
|
||||
);
|
||||
|
||||
// Cache mirrors the DB row exactly: SECRET stores ciphertext, PLAIN stores
|
||||
// plaintext. Reads route through prepareForRead, which decrypts based on
|
||||
// the row's current `type`.
|
||||
await NocoCache.update(
|
||||
context,
|
||||
`${CacheScope.BASE_VARIABLE}:${variableId}`,
|
||||
updateObj,
|
||||
);
|
||||
|
||||
return this.get(context, variableId, ncMeta);
|
||||
}
|
||||
|
||||
public static async delete(
|
||||
context: NcContext,
|
||||
variableId: string,
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
const res = await ncMeta.metaDelete(
|
||||
context.workspace_id,
|
||||
context.base_id,
|
||||
MetaTable.BASE_VARIABLES,
|
||||
variableId,
|
||||
);
|
||||
|
||||
await NocoCache.deepDel(
|
||||
context,
|
||||
`${CacheScope.BASE_VARIABLE}:${variableId}`,
|
||||
CacheDelDirection.CHILD_TO_PARENT,
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public static async deleteByBaseId(
|
||||
context: NcContext,
|
||||
baseId: string,
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
await ncMeta.metaDelete(
|
||||
context.workspace_id,
|
||||
context.base_id,
|
||||
MetaTable.BASE_VARIABLES,
|
||||
{ base_id: baseId },
|
||||
);
|
||||
|
||||
await NocoCache.deepDel(
|
||||
context,
|
||||
`${CacheScope.BASE_VARIABLE}:${baseId}:list`,
|
||||
CacheDelDirection.PARENT_TO_CHILD,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,14 @@ export default class CalendarViewColumn {
|
||||
|
||||
{
|
||||
const view = await View.get(context, column.fk_view_id, false, ncMeta);
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
if (view) {
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.get(context, id, ncMeta).then(async (viewColumn) => {
|
||||
|
||||
@@ -1984,9 +1984,17 @@ export default class Column<T = any> implements ColumnType {
|
||||
const columns = [];
|
||||
|
||||
// add fk_model_id
|
||||
const isReplay = !!context?.additionalContext?.is_replay;
|
||||
const sandboxColumnIds: Record<string, string> | undefined = isReplay
|
||||
? context.additionalContext?.sandboxColumnIds
|
||||
: undefined;
|
||||
for (const column of param.columns) {
|
||||
// pre-populate column meta to use while inserting colOptions
|
||||
const id = await ncMeta.genNanoid(MetaTable.COLUMNS);
|
||||
const id =
|
||||
(isReplay && column.id) ||
|
||||
sandboxColumnIds?.[column.column_name] ||
|
||||
sandboxColumnIds?.[column.title] ||
|
||||
(await ncMeta.genNanoid(MetaTable.COLUMNS));
|
||||
const colWithId = {
|
||||
...column,
|
||||
id,
|
||||
|
||||
@@ -377,12 +377,16 @@ export default class Filter implements FilterType {
|
||||
if (filter.fk_view_id) {
|
||||
const view = await View.get(context, filter.fk_view_id, false, ncMeta);
|
||||
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
// View may be missing if it was deleted concurrently or the filter
|
||||
// is orphaned — skip cache invalidation rather than throwing.
|
||||
if (view) {
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,14 +442,16 @@ export default class Filter implements FilterType {
|
||||
{
|
||||
const filter = await this.get(context, id, ncMeta);
|
||||
// if not a view filter then no need to delete
|
||||
if (filter.fk_view_id) {
|
||||
if (filter?.fk_view_id) {
|
||||
const view = await View.get(context, filter.fk_view_id, false, ncMeta);
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[{ id: filter.fk_view_id }],
|
||||
ncMeta,
|
||||
);
|
||||
if (view) {
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[{ id: filter.fk_view_id }],
|
||||
ncMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,12 +488,14 @@ export default class Filter implements FilterType {
|
||||
if (filter.fk_view_id) {
|
||||
const view = await View.get(context, filter.fk_view_id, false, ncMeta);
|
||||
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[{ id: filter.fk_view_id }],
|
||||
ncMeta,
|
||||
);
|
||||
if (view) {
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[{ id: filter.fk_view_id }],
|
||||
ncMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,12 +809,14 @@ export default class Filter implements FilterType {
|
||||
// on update delete any optimised single query cache
|
||||
{
|
||||
const view = await View.get(context, viewId, false, ncMeta);
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
if (view) {
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,12 +91,14 @@ export default class GalleryViewColumn {
|
||||
// on new view column, delete any optimised single query cache
|
||||
{
|
||||
const view = await View.get(context, column.fk_view_id, false, ncMeta);
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
if (view) {
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.get(context, id, ncMeta).then(async (viewColumn) => {
|
||||
|
||||
@@ -155,12 +155,14 @@ export default class GridViewColumn implements GridColumnType {
|
||||
// on new view column, delete any optimised single query cache
|
||||
{
|
||||
const view = await View.get(context, column.fk_view_id, false, ncMeta);
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
if (view) {
|
||||
await View.clearSingleQueryCache(
|
||||
context,
|
||||
view.fk_model_id,
|
||||
[view],
|
||||
ncMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.get(context, id, ncMeta).then(async (viewColumn) => {
|
||||
|
||||
@@ -238,6 +238,11 @@ export default class Hook implements HookType {
|
||||
insertObj.notification = JSON.stringify(insertObj.notification);
|
||||
}
|
||||
|
||||
// Replay-only: preserve sandbox entity ID for idempotent merge
|
||||
if (context?.additionalContext?.is_replay && hook.id) {
|
||||
insertObj.id = hook.id;
|
||||
}
|
||||
|
||||
const model = await Model.getByIdOrName(
|
||||
context,
|
||||
{ id: hook.fk_model_id },
|
||||
@@ -326,6 +331,11 @@ export default class Hook implements HookType {
|
||||
insertObj.notification = JSON.stringify(insertObj.notification);
|
||||
}
|
||||
|
||||
// Replay-only: preserve sandbox entity ID for idempotent merge
|
||||
if (context?.additionalContext?.is_replay && hook.id) {
|
||||
insertObj.id = hook.id;
|
||||
}
|
||||
|
||||
const model = await Model.getByIdOrName(
|
||||
context,
|
||||
{ id: hook.fk_model_id },
|
||||
|
||||
@@ -216,6 +216,9 @@ export default class Model implements TableType {
|
||||
type?: ModelTypes;
|
||||
source_id?: string;
|
||||
user_id: string;
|
||||
// Sandbox-replay only: preserve the master view ID when recreating the
|
||||
// auto-created default view, so sorts/filters referencing it still resolve.
|
||||
_sandboxDefaultViewId?: string;
|
||||
},
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
@@ -290,6 +293,9 @@ export default class Model implements TableType {
|
||||
context,
|
||||
{
|
||||
view: {
|
||||
...(model._sandboxDefaultViewId
|
||||
? { id: model._sandboxDefaultViewId }
|
||||
: {}),
|
||||
fk_model_id: id,
|
||||
title: model.title || model.table_name,
|
||||
type: ViewTypes.GRID,
|
||||
|
||||
@@ -1263,6 +1263,7 @@ export default class View implements ViewType {
|
||||
ncMeta = Noco.ncMeta,
|
||||
) {
|
||||
const view = await this.get(context, viewId, false, ncMeta);
|
||||
if (!view) NcError.viewNotFound(viewId);
|
||||
let table;
|
||||
let cacheScope;
|
||||
switch (view.type) {
|
||||
@@ -1426,6 +1427,7 @@ export default class View implements ViewType {
|
||||
| any
|
||||
> {
|
||||
const view = await this.get(context, viewId, false, ncMeta);
|
||||
if (!view) NcError.viewNotFound(viewId);
|
||||
const table = this.extractViewColumnsTableName(view);
|
||||
|
||||
const existingCol = await ncMeta.metaGet2(
|
||||
|
||||
@@ -48,6 +48,7 @@ export { default as Notification } from './Notification';
|
||||
export { default as PresignedUrl } from './PresignedUrl';
|
||||
export { default as UserRefreshToken } from './UserRefreshToken';
|
||||
export { default as Extension } from './Extension';
|
||||
export { default as BaseVariable } from './BaseVariable';
|
||||
export { default as Comment } from './Comment';
|
||||
export { default as Job } from './Job';
|
||||
export { default as Integration } from './Integration';
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '~/utils';
|
||||
import { type Base, Model, Source } from '~/models';
|
||||
import { sanitizeColumnName } from '~/helpers';
|
||||
import { OperationSource } from '~/helpers/columnHelpers';
|
||||
import { AttachmentsService } from '~/services/attachments.service';
|
||||
import { ColumnsService } from '~/services/columns.service';
|
||||
import { BulkDataAliasService } from '~/services/bulk-data-alias.service';
|
||||
@@ -761,6 +762,7 @@ export class AtImportProcessor {
|
||||
table: tables[idx],
|
||||
user: syncDB.user,
|
||||
req,
|
||||
operationSource: OperationSource.AT_IMPORT,
|
||||
});
|
||||
recordPerfStats(_perfStart, 'dbTable.create');
|
||||
|
||||
@@ -914,6 +916,7 @@ export class AtImportProcessor {
|
||||
},
|
||||
req,
|
||||
user: syncDB.user,
|
||||
operationSource: OperationSource.AT_IMPORT,
|
||||
});
|
||||
recordPerfStats(_perfStart, 'dbTableColumn.create');
|
||||
|
||||
@@ -1140,6 +1143,7 @@ export class AtImportProcessor {
|
||||
},
|
||||
req,
|
||||
user: syncDB.user,
|
||||
operationSource: OperationSource.AT_IMPORT,
|
||||
});
|
||||
recordPerfStats(_perfStart, 'dbTableColumn.create');
|
||||
|
||||
@@ -1226,6 +1230,7 @@ export class AtImportProcessor {
|
||||
clientIp: '',
|
||||
} as any,
|
||||
user: syncDB.user,
|
||||
operationSource: OperationSource.AT_IMPORT,
|
||||
});
|
||||
recordPerfStats(_perfStart, 'dbTableColumn.create');
|
||||
|
||||
@@ -1390,6 +1395,7 @@ export class AtImportProcessor {
|
||||
},
|
||||
req,
|
||||
user: syncDB.user,
|
||||
operationSource: OperationSource.AT_IMPORT,
|
||||
});
|
||||
recordPerfStats(_perfStart, 'dbTableColumn.create');
|
||||
|
||||
@@ -1457,6 +1463,7 @@ export class AtImportProcessor {
|
||||
},
|
||||
req,
|
||||
user: syncDB.user,
|
||||
operationSource: OperationSource.AT_IMPORT,
|
||||
});
|
||||
recordPerfStats(_perfStart, 'dbTableColumn.create');
|
||||
|
||||
|
||||
@@ -380,7 +380,11 @@ export class DuplicateProcessor {
|
||||
dataSource: source,
|
||||
req,
|
||||
context,
|
||||
// Spread original options first so EE-only flags (excludeDocuments,
|
||||
// excludePersonalViews, excludePermissions, excludeRls, etc.) reach the
|
||||
// processor. Normalized CE flags override after.
|
||||
options: {
|
||||
...(options ?? {}),
|
||||
excludeData,
|
||||
excludeHooks,
|
||||
excludeViews,
|
||||
|
||||
@@ -54,6 +54,12 @@ export class DuplicateService {
|
||||
NcError.get(context).baseNotFound(baseId);
|
||||
}
|
||||
|
||||
if (base.is_sandbox) {
|
||||
NcError.get(context).badRequest(
|
||||
'Sandbox bases cannot be duplicated. Duplicate the master base instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const source = sourceId
|
||||
? await Source.get(context, sourceId)
|
||||
: (await base.getSources())[0];
|
||||
|
||||
@@ -1300,24 +1300,50 @@ export class ImportService {
|
||||
// create filters
|
||||
const filters = colOptions.filter?.children;
|
||||
|
||||
// Track filters that fail to create so descendants can be
|
||||
// skipped too — otherwise a child whose fk_parent_id maps
|
||||
// to undefined would be persisted at root level, leaving
|
||||
// orphan filters that are hard to clean up later.
|
||||
const failedFilterIds = new Set<string>();
|
||||
|
||||
for (const fl of filters) {
|
||||
const fg = await this.filtersService.linkFilterCreate(
|
||||
targetContext,
|
||||
{
|
||||
columnId: getIdOrExternalId(col.id),
|
||||
filter: withoutId({
|
||||
...fl,
|
||||
fk_value_col_id: getIdOrExternalId(fl.fk_value_col_id),
|
||||
fk_link_col_id: getIdOrExternalId(fl.fk_link_col_id),
|
||||
fk_column_id: getIdOrExternalId(fl.fk_column_id),
|
||||
fk_parent_id: getIdOrExternalId(fl.fk_parent_id),
|
||||
}),
|
||||
user: param.user,
|
||||
req: param.req,
|
||||
},
|
||||
);
|
||||
if (fg) {
|
||||
idMap.set(fl.id, fg.id);
|
||||
if (fl.fk_parent_id && failedFilterIds.has(fl.fk_parent_id)) {
|
||||
failedFilterIds.add(fl.id);
|
||||
this.logger.warn(
|
||||
`Skipping link filter ${fl.id} on column ${col.id} — parent ${fl.fk_parent_id} failed earlier in this import`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A single broken link filter (e.g. column lookup resolves
|
||||
// to undefined when the source export references something
|
||||
// outside the duplicated scope) shouldn't abort the entire
|
||||
// base duplication — log and skip.
|
||||
try {
|
||||
const fg = await this.filtersService.linkFilterCreate(
|
||||
targetContext,
|
||||
{
|
||||
columnId: getIdOrExternalId(col.id),
|
||||
filter: withoutId({
|
||||
...fl,
|
||||
fk_value_col_id: getIdOrExternalId(fl.fk_value_col_id),
|
||||
fk_link_col_id: getIdOrExternalId(fl.fk_link_col_id),
|
||||
fk_column_id: getIdOrExternalId(fl.fk_column_id),
|
||||
fk_parent_id: getIdOrExternalId(fl.fk_parent_id),
|
||||
}),
|
||||
user: param.user,
|
||||
req: param.req,
|
||||
},
|
||||
);
|
||||
if (fg) {
|
||||
idMap.set(fl.id, fg.id);
|
||||
}
|
||||
} catch (e) {
|
||||
failedFilterIds.add(fl.id);
|
||||
this.logger.warn(
|
||||
`Skipping link filter ${fl.id} on column ${col.id} during import: ${e?.message}`,
|
||||
e?.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1406,6 +1432,12 @@ export class ImportService {
|
||||
!getIdOrExternalId(colOptions.fk_lookup_column_id)
|
||||
) {
|
||||
if (colOptions.error) {
|
||||
// NOTE: bypasses `columnsService.columnAdd` (and its `@TraceCommand`)
|
||||
// intentionally — `columnAdd` validates references and would throw
|
||||
// for an unmapped Lookup. Error-state columns are a degraded
|
||||
// outcome of cross-base imports where the lookup target couldn't be
|
||||
// resolved; sandbox-replay won't reproduce them, but the original
|
||||
// column is already broken so this is acceptable.
|
||||
const tableId = getIdOrExternalId(getParentIdentifier(col.id));
|
||||
const insertedColumn = await Column.insert(targetContext, {
|
||||
...withoutId(flatCol),
|
||||
@@ -1460,6 +1492,7 @@ export class ImportService {
|
||||
!getIdOrExternalId(colOptions.fk_rollup_column_id)
|
||||
) {
|
||||
if (colOptions.error) {
|
||||
// See Lookup branch above — same rationale applies here.
|
||||
const tableId = getIdOrExternalId(getParentIdentifier(col.id));
|
||||
const insertedColumn = await Column.insert(targetContext, {
|
||||
...withoutId(flatCol),
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Readable } from 'stream';
|
||||
import debug from 'debug';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
import { useAgent } from 'request-filtering-agent';
|
||||
import type { NcContext, NcRequest } from '~/interface/config';
|
||||
import type { Base, Source } from '~/models';
|
||||
import { NcError } from '~/helpers/ncError';
|
||||
import { assertNotSandbox } from '~/helpers/sandboxGuards';
|
||||
import { ExportService } from '~/modules/jobs/jobs/export-import/export.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -27,6 +29,11 @@ export class MigrateService {
|
||||
instanceUrl: string;
|
||||
req: NcRequest;
|
||||
}) {
|
||||
await assertNotSandbox(
|
||||
context,
|
||||
'Migrating a base is not allowed from a sandbox. Run the migration on the production base.',
|
||||
);
|
||||
|
||||
if (!base) {
|
||||
NcError.get(context).baseNotFound('Base not found!');
|
||||
}
|
||||
@@ -65,12 +72,16 @@ export class MigrateService {
|
||||
stream.push(JSON.stringify(data));
|
||||
};
|
||||
|
||||
const targetUrl = `${instanceUrl}/api/v2/meta/duplicate/remote/${secret}`;
|
||||
|
||||
const axiosPromise = axios({
|
||||
method: 'post',
|
||||
url: `${instanceUrl}/api/v2/meta/duplicate/remote/${secret}`,
|
||||
url: targetUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
httpAgent: useAgent(targetUrl),
|
||||
httpsAgent: useAgent(targetUrl),
|
||||
data: stream,
|
||||
maxBodyLength: Infinity,
|
||||
}).catch((e) => {
|
||||
|
||||
@@ -46,14 +46,6 @@ const PARALLEL_LIMIT =
|
||||
// TODO: Drop nc_temp_processed_soft_delete after migration is confirmed complete across all deployments
|
||||
const TEMP_TABLE = 'nc_temp_processed_soft_delete';
|
||||
|
||||
// Comma-separated workspace IDs to target during dry-run.
|
||||
// When set, the migration only processes models in these workspaces.
|
||||
const TARGET_WORKSPACE_IDS = process.env.NC_TRASH_MIGRATION_WORKSPACE_IDS
|
||||
? process.env.NC_TRASH_MIGRATION_WORKSPACE_IDS.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
const propsByClientType = {};
|
||||
|
||||
const memoizedGetColumnPropsFromUIDT = async (
|
||||
@@ -138,30 +130,16 @@ export class SoftDeleteColumnMigration {
|
||||
builder.where(`${MetaTable.SOURCES}.is_meta`, true);
|
||||
builder.orWhere({ is_local: true });
|
||||
})
|
||||
.whereNotIn(
|
||||
`${MetaTable.MODELS}.id`,
|
||||
ncMeta.knexConnection(TEMP_TABLE).select('fk_model_id'),
|
||||
.whereNotExists((qb) =>
|
||||
qb
|
||||
.select(1)
|
||||
.from(TEMP_TABLE)
|
||||
.whereRaw(`${TEMP_TABLE}.fk_model_id = ${MetaTable.MODELS}.id`),
|
||||
)
|
||||
.modify((qb) => {
|
||||
if (TARGET_WORKSPACE_IDS) {
|
||||
qb.whereIn(
|
||||
`${MetaTable.MODELS}.fk_workspace_id`,
|
||||
TARGET_WORKSPACE_IDS,
|
||||
);
|
||||
}
|
||||
})
|
||||
.count('*', { as: 'count' })
|
||||
.first()
|
||||
)?.count;
|
||||
|
||||
if (TARGET_WORKSPACE_IDS) {
|
||||
this.log(
|
||||
`Dry-run mode: targeting ${
|
||||
TARGET_WORKSPACE_IDS.length
|
||||
} workspace(s): ${TARGET_WORKSPACE_IDS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.log(
|
||||
`Found ${numberOfModelsToBeProcessed} models to process for soft delete + meta column`,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,8 @@ server.disable('etag');
|
||||
server.disable('x-powered-by');
|
||||
server.use(
|
||||
cors({
|
||||
exposedHeaders: 'xc-db-response',
|
||||
exposedHeaders:
|
||||
'xc-db-response, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Policy, Retry-After',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ server.disable('etag');
|
||||
server.disable('x-powered-by');
|
||||
server.use(
|
||||
cors({
|
||||
exposedHeaders: 'xc-db-response',
|
||||
exposedHeaders:
|
||||
'xc-db-response, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Policy, Retry-After',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ server.disable('etag');
|
||||
server.disable('x-powered-by');
|
||||
server.use(
|
||||
cors({
|
||||
exposedHeaders: 'xc-db-response',
|
||||
exposedHeaders:
|
||||
'xc-db-response, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Policy, Retry-After',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ server.disable('etag');
|
||||
server.disable('x-powered-by');
|
||||
server.use(
|
||||
cors({
|
||||
exposedHeaders: 'xc-db-response',
|
||||
exposedHeaders:
|
||||
'xc-db-response, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Policy, Retry-After',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ server.disable('etag');
|
||||
server.disable('x-powered-by');
|
||||
server.use(
|
||||
cors({
|
||||
exposedHeaders: 'xc-db-response',
|
||||
exposedHeaders:
|
||||
'xc-db-response, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Policy, Retry-After',
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18845,9 +18845,9 @@
|
||||
"$ref": "#/components/schemas/Bool",
|
||||
"description": "Indicates if the base is a sandbox"
|
||||
},
|
||||
"is_sandbox_master": {
|
||||
"is_sandbox_production": {
|
||||
"$ref": "#/components/schemas/Bool",
|
||||
"description": "Indicates if the base is a sandbox master"
|
||||
"description": "Indicates if the base is a sandbox production base (has at least one sandbox derived from it)"
|
||||
}
|
||||
},
|
||||
"x-stoplight": {
|
||||
|
||||
@@ -21067,6 +21067,33 @@
|
||||
"in": "query",
|
||||
"description": "Team ID",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": "modelId",
|
||||
"in": "query",
|
||||
"description": "Model ID",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": "rowId",
|
||||
"in": "query",
|
||||
"description": "Row ID",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": "urlOrPath",
|
||||
"in": "query",
|
||||
"description": "URL or Path of the attachment",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -27170,9 +27197,9 @@
|
||||
"$ref": "#/components/schemas/Bool",
|
||||
"description": "Indicates if the base is a sandbox"
|
||||
},
|
||||
"is_sandbox_master": {
|
||||
"is_sandbox_production": {
|
||||
"$ref": "#/components/schemas/Bool",
|
||||
"description": "Indicates if the base is a sandbox master"
|
||||
"description": "Indicates if the base is a sandbox production base (has at least one sandbox derived from it)"
|
||||
}
|
||||
},
|
||||
"x-stoplight": {
|
||||
|
||||
@@ -11,12 +11,13 @@ import axios from 'axios';
|
||||
import hash from 'object-hash';
|
||||
import moment from 'moment';
|
||||
import { useAgent } from 'request-filtering-agent';
|
||||
import type { AttachmentReqType, FileType } from 'nocodb-sdk';
|
||||
import type { AttachmentReqType, FileType, NcContext } from 'nocodb-sdk';
|
||||
import type { NcRequest } from '~/interface/config';
|
||||
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
|
||||
import { DataTableService } from '~/services/data-table.service';
|
||||
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
|
||||
import { mimeIcons } from '~/utils/mimeTypes';
|
||||
import { FileReference, PresignedUrl } from '~/models';
|
||||
import { Column, FileReference, PresignedUrl } from '~/models';
|
||||
import { utf8ify } from '~/helpers/stringHelpers';
|
||||
import { NcBaseError, NcError } from '~/helpers/catchError';
|
||||
import { IJobsService } from '~/modules/jobs/jobs-service.interface';
|
||||
@@ -52,6 +53,8 @@ export class AttachmentsService {
|
||||
private readonly appHooksService: AppHooksService,
|
||||
@Inject(forwardRef(() => 'JobsService'))
|
||||
private readonly jobsService: IJobsService,
|
||||
@Inject(forwardRef(() => DataTableService))
|
||||
private readonly dataTableService: DataTableService,
|
||||
) {}
|
||||
|
||||
async upload(param: {
|
||||
@@ -494,6 +497,43 @@ export class AttachmentsService {
|
||||
return { path: filePath, type };
|
||||
}
|
||||
|
||||
async downloadAttachment(
|
||||
context: NcContext,
|
||||
param: {
|
||||
modelId: string;
|
||||
columnId: string;
|
||||
rowId: string;
|
||||
urlOrPath: string;
|
||||
},
|
||||
) {
|
||||
const column = await Column.get(context, {
|
||||
colId: param.columnId,
|
||||
});
|
||||
|
||||
if (!column) {
|
||||
NcError.fieldNotFound(param.columnId);
|
||||
}
|
||||
|
||||
const record = await this.dataTableService.dataRead(context, {
|
||||
baseId: context.base_id,
|
||||
modelId: param.modelId,
|
||||
rowId: param.rowId,
|
||||
query: {
|
||||
fields: column.title,
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
NcError.recordNotFound(param.rowId);
|
||||
}
|
||||
|
||||
return this.getAttachmentFromRecord({
|
||||
record,
|
||||
column,
|
||||
urlOrPath: param.urlOrPath,
|
||||
});
|
||||
}
|
||||
|
||||
async getAttachmentFromRecord(param: {
|
||||
record: any;
|
||||
column: { title: string };
|
||||
|
||||
@@ -5,8 +5,9 @@ import type {
|
||||
UserType,
|
||||
ViewCreateReqType,
|
||||
} from 'nocodb-sdk';
|
||||
import type { NcContext, NcRequest } from '~/interface/config';
|
||||
import type { MetaService } from '~/meta/meta.service';
|
||||
import type { NcRequest } from '~/interface/config';
|
||||
import { NcContext } from '~/interface/config';
|
||||
import { MetaService } from '~/meta/meta.service';
|
||||
import {
|
||||
type ViewWebhookManager,
|
||||
ViewWebhookManagerBuilder,
|
||||
@@ -14,7 +15,10 @@ import {
|
||||
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
|
||||
import { validatePayload } from '~/helpers';
|
||||
import { assertPersonalViewAllowed } from '~/helpers/checkPersonalViewFeature';
|
||||
import { assertNotSandbox } from '~/helpers/sandboxGuards';
|
||||
import { NcError } from '~/helpers/catchError';
|
||||
import { TraceCommand } from '~/decorators/trace-command.decorator';
|
||||
import { OperationName } from '~/command-registry/op-names';
|
||||
import { CalendarView, Model, User, View } from '~/models';
|
||||
import NocoCache from '~/cache/NocoCache';
|
||||
import { CacheScope } from '~/utils/globals';
|
||||
@@ -22,12 +26,13 @@ import NocoSocket from '~/socket/NocoSocket';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsService {
|
||||
constructor(private readonly appHooksService: AppHooksService) {}
|
||||
constructor(protected readonly appHooksService: AppHooksService) {}
|
||||
|
||||
async calendarViewGet(context: NcContext, param: { calendarViewId: string }) {
|
||||
return await CalendarView.get(context, param.calendarViewId);
|
||||
}
|
||||
|
||||
@TraceCommand(OperationName.calendarViewCreate)
|
||||
async calendarViewCreate(
|
||||
context: NcContext,
|
||||
param: {
|
||||
@@ -40,6 +45,13 @@ export class CalendarsService {
|
||||
},
|
||||
ncMeta?: MetaService,
|
||||
) {
|
||||
if (param?.ownedBy) {
|
||||
await assertNotSandbox(
|
||||
context,
|
||||
'Personal views cannot be created in a sandbox. Create them on the production base.',
|
||||
);
|
||||
}
|
||||
|
||||
validatePayload(
|
||||
'swagger.json#/components/schemas/ViewCreateReq',
|
||||
param.calendar,
|
||||
@@ -146,6 +158,7 @@ export class CalendarsService {
|
||||
return view;
|
||||
}
|
||||
|
||||
@TraceCommand(OperationName.calendarViewUpdate)
|
||||
async calendarViewUpdate(
|
||||
context: NcContext,
|
||||
param: {
|
||||
|
||||
@@ -50,12 +50,14 @@ import type {
|
||||
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
|
||||
import type CustomKnex from '~/db/CustomKnex';
|
||||
import type SqlMgrv2 from '~/db/sql-mgr/v2/SqlMgrv2';
|
||||
import type { NcContext, NcRequest } from '~/interface/config';
|
||||
import type { NcRequest } from '~/interface/config';
|
||||
import type { Base, LinkToAnotherRecordColumn } from '~/models';
|
||||
import type {
|
||||
IColumnsService,
|
||||
LtarSideEffectIds,
|
||||
ReusableParams,
|
||||
} from '~/services/columns.service.type';
|
||||
import { NcContext } from '~/interface/config';
|
||||
import {
|
||||
type ColumnWebhookManager,
|
||||
ColumnWebhookManagerBuilder,
|
||||
@@ -68,6 +70,7 @@ import {
|
||||
createHmAndBtColumn,
|
||||
createOOColumn,
|
||||
deleteColumnSystemPropsFromRequest,
|
||||
type OperationSource,
|
||||
generateFkName,
|
||||
getMMColumnNames,
|
||||
getRevType,
|
||||
@@ -77,6 +80,8 @@ import {
|
||||
validateRequiredField,
|
||||
validateRollupPayload,
|
||||
} from '~/helpers';
|
||||
import { TraceCommand } from '~/decorators/trace-command.decorator';
|
||||
import { OperationName } from '~/command-registry/op-names';
|
||||
import { NcError } from '~/helpers/catchError';
|
||||
import { extractProps } from '~/helpers/extractProps';
|
||||
import { pgQuoteLiteral } from '~/helpers/sqlSanitize';
|
||||
@@ -125,7 +130,10 @@ import { getRelatedModelMap } from '~/utils/getRelatedModelMap';
|
||||
import { validateColumnInternalMeta } from '~/types/column-internal-meta';
|
||||
import { backfillAutoNumber } from '~/helpers/autonumberHelpers';
|
||||
|
||||
export type { ReusableParams } from '~/services/columns.service.type';
|
||||
export type {
|
||||
LtarSideEffectIds,
|
||||
ReusableParams,
|
||||
} from '~/services/columns.service.type';
|
||||
|
||||
const deepClone = rfdc();
|
||||
|
||||
@@ -1499,7 +1507,7 @@ export class ColumnsService implements IColumnsService {
|
||||
const existingOptions = colBody.colOptions.options.map(
|
||||
(el) => el.title,
|
||||
);
|
||||
const options = data.reduce((acc, el) => {
|
||||
const options = data.reduce<{ title: string }[]>((acc, el) => {
|
||||
if (el[column.column_name]) {
|
||||
const values = String(el[column.column_name]).split(',');
|
||||
if (values.length > 1) {
|
||||
@@ -2802,6 +2810,7 @@ export class ColumnsService implements IColumnsService {
|
||||
return Column.get(context, { colId: param.columnId });
|
||||
}
|
||||
|
||||
@TraceCommand(OperationName.columnSetAsPrimary)
|
||||
async columnSetAsPrimary(
|
||||
context: NcContext,
|
||||
param: { columnId: string; req: NcRequest },
|
||||
@@ -2890,6 +2899,13 @@ export class ColumnsService implements IColumnsService {
|
||||
suppressFormulaError?: boolean;
|
||||
apiVersion?: T;
|
||||
columnWebhookManager?: ColumnWebhookManager;
|
||||
operationSource?: OperationSource;
|
||||
// Sandbox-replay LTAR side-effect IDs — set by the columnAdd handler
|
||||
// on replay, threaded down into `createLTARColumn`.
|
||||
_ltarReplayIds?: LtarSideEffectIds;
|
||||
// Capture slot populated by `createLTARColumn` during recording;
|
||||
// surfaced via `extraCommandMeta` on `ColumnAddContract`.
|
||||
_ltarCapture?: LtarSideEffectIds;
|
||||
},
|
||||
ncMeta = Noco.ncMeta,
|
||||
): Promise<T extends NcApiVersion.V3 ? Column : Model> {
|
||||
@@ -2941,7 +2957,9 @@ export class ColumnsService implements IColumnsService {
|
||||
`Cannot manually create system columns`,
|
||||
);
|
||||
} else {
|
||||
deleteColumnSystemPropsFromRequest(param.column);
|
||||
deleteColumnSystemPropsFromRequest(param.column, {
|
||||
operationSource: param.operationSource,
|
||||
});
|
||||
}
|
||||
|
||||
const base = await reuseOrSave('base', reuse, async () =>
|
||||
@@ -3110,14 +3128,27 @@ export class ColumnsService implements IColumnsService {
|
||||
break;
|
||||
|
||||
case UITypes.Links:
|
||||
case UITypes.LinkToAnotherRecord:
|
||||
case UITypes.LinkToAnotherRecord: {
|
||||
// Sandbox-replay capture slot — populated by `createLTARColumn` with
|
||||
// side-effect IDs (assoc model, FK cols, back-link cols, reverse LTAR)
|
||||
// and read by `extraCommandMeta` on `ColumnAddContract` to thread
|
||||
// them into the changelog. Filtered from replay params via
|
||||
// `NON_SERIALIZABLE_KEYS`. Object reference is shared so inner
|
||||
// mutations are visible to the calling decorator.
|
||||
const ltarCapture: LtarSideEffectIds = {};
|
||||
param._ltarCapture = ltarCapture;
|
||||
savedColumn = await this.createLTARColumn(context, {
|
||||
...param,
|
||||
tableId: param.tableId,
|
||||
column: param.column,
|
||||
user: param.user,
|
||||
req: param.req,
|
||||
reuse: param.reuse,
|
||||
columnWebhookManager,
|
||||
_ltarReplayIds: param._ltarReplayIds,
|
||||
_ltarCapture: ltarCapture,
|
||||
source,
|
||||
base,
|
||||
reuse,
|
||||
colExtra,
|
||||
columnWebhookManager,
|
||||
});
|
||||
|
||||
this.appHooksService.emit(AppEvents.RELATION_CREATE, {
|
||||
@@ -3131,6 +3162,7 @@ export class ColumnsService implements IColumnsService {
|
||||
context,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case UITypes.QrCode:
|
||||
validateParams(['fk_qr_value_column_id'], param.column, context);
|
||||
@@ -5236,9 +5268,19 @@ export class ColumnsService implements IColumnsService {
|
||||
user: UserType;
|
||||
req: NcRequest;
|
||||
columnWebhookManager?: ColumnWebhookManager;
|
||||
// Sandbox-replay only — set by the columnAdd handler when replaying a
|
||||
// recorded LTAR create. Each insert site below honors the matching id
|
||||
// so dependent ops (Lookup/Rollup/linkFilter) keep stable references.
|
||||
_ltarReplayIds?: LtarSideEffectIds;
|
||||
// Sandbox-replay only — capture slot populated during recording so
|
||||
// `extraCommandMeta` on `ColumnAddContract` can thread the side-effect
|
||||
// IDs into the changelog. Object ref shared with `columnAdd`'s param.
|
||||
_ltarCapture?: LtarSideEffectIds;
|
||||
},
|
||||
) {
|
||||
let savedColumn: Column;
|
||||
const replayIds = param._ltarReplayIds;
|
||||
const capture = param._ltarCapture;
|
||||
|
||||
if ((param.column as any).is_custom_link) {
|
||||
NcError.get(context).badRequest(
|
||||
@@ -5256,6 +5298,9 @@ export class ColumnsService implements IColumnsService {
|
||||
readonly?: boolean;
|
||||
meta?: Record<string, any>;
|
||||
ref_base_id?: string;
|
||||
// Sandbox-replay — pre-injected by `idField: 'column'` so each
|
||||
// back-link/oo/mm `Column.insert` can honor the recorded id.
|
||||
id?: string;
|
||||
};
|
||||
|
||||
if (!ltarReq.parentId) {
|
||||
@@ -5407,12 +5452,14 @@ export class ColumnsService implements IColumnsService {
|
||||
await refSqlMgr.sqlOpPlus(refSource, 'tableUpdate', tableUpdateBody);
|
||||
|
||||
const { id } = await Column.insert(refContext, {
|
||||
...(replayIds?.fkColumnId ? { id: replayIds.fkColumnId } : {}),
|
||||
...newColumn,
|
||||
uidt: UITypes.ForeignKey,
|
||||
fk_model_id: refTable.id,
|
||||
});
|
||||
|
||||
refColumn = await Column.get(refContext, { colId: id });
|
||||
if (capture) capture.fkColumnId = refColumn.id;
|
||||
|
||||
// ignore relation creation if virtual
|
||||
if (!ltarReq.virtual) {
|
||||
@@ -5446,6 +5493,7 @@ export class ColumnsService implements IColumnsService {
|
||||
}
|
||||
}
|
||||
|
||||
const hmBtOut: { childRelColId?: string; savedColumnId?: string } = {};
|
||||
savedColumn = await createHmAndBtColumn(
|
||||
context,
|
||||
param.req,
|
||||
@@ -5467,7 +5515,13 @@ export class ColumnsService implements IColumnsService {
|
||||
undefined,
|
||||
undefined,
|
||||
param.columnWebhookManager,
|
||||
{
|
||||
childRelColId: replayIds?.reverseColumnId,
|
||||
savedColumnId: ltarReq.id,
|
||||
},
|
||||
hmBtOut,
|
||||
);
|
||||
if (capture) capture.reverseColumnId = hmBtOut.childRelColId;
|
||||
} else if (!isMMLike && ltarReq.type === 'oo') {
|
||||
// populate fk column name
|
||||
const fkColName = getUniqueColumnName(
|
||||
@@ -5515,12 +5569,14 @@ export class ColumnsService implements IColumnsService {
|
||||
await sqlMgr.sqlOpPlus(refSource, 'tableUpdate', tableUpdateBody);
|
||||
|
||||
const { id } = await Column.insert(refContext, {
|
||||
...(replayIds?.fkColumnId ? { id: replayIds.fkColumnId } : {}),
|
||||
...newColumn,
|
||||
uidt: UITypes.ForeignKey,
|
||||
fk_model_id: refTable.id,
|
||||
});
|
||||
|
||||
refColumn = await Column.get(refContext, { colId: id });
|
||||
if (capture) capture.fkColumnId = refColumn.id;
|
||||
|
||||
// ignore relation creation if virtual
|
||||
if (!ltarReq.virtual) {
|
||||
@@ -5553,6 +5609,7 @@ export class ColumnsService implements IColumnsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
const ooOut: { childRelColId?: string; savedColumnId?: string } = {};
|
||||
savedColumn = await createOOColumn(
|
||||
context,
|
||||
param.req,
|
||||
@@ -5573,7 +5630,13 @@ export class ColumnsService implements IColumnsService {
|
||||
undefined,
|
||||
undefined,
|
||||
param.columnWebhookManager,
|
||||
{
|
||||
childRelColId: replayIds?.reverseColumnId,
|
||||
savedColumnId: ltarReq.id,
|
||||
},
|
||||
ooOut,
|
||||
);
|
||||
if (capture) capture.reverseColumnId = ooOut.childRelColId;
|
||||
} else if (isMMLike || ltarReq.type === 'mm') {
|
||||
const aTn = await getJunctionTableName(param, table, refTable);
|
||||
const aTnAlias = aTn;
|
||||
@@ -5590,6 +5653,12 @@ export class ColumnsService implements IColumnsService {
|
||||
|
||||
associateTableCols.push(
|
||||
{
|
||||
// Pre-set ID on replay so `Column.bulkInsert` honors it (the
|
||||
// `column_name` map lookup wouldn't match — assoc-table column
|
||||
// names embed the source prefix which differs across bases).
|
||||
...(replayIds?.assocChildColId
|
||||
? { id: replayIds.assocChildColId }
|
||||
: {}),
|
||||
cn: refColumnName,
|
||||
column_name: refColumnName,
|
||||
title: refColumnName,
|
||||
@@ -5605,6 +5674,9 @@ export class ColumnsService implements IColumnsService {
|
||||
uidt: UITypes.ForeignKey,
|
||||
},
|
||||
{
|
||||
...(replayIds?.assocParentColId
|
||||
? { id: replayIds.assocParentColId }
|
||||
: {}),
|
||||
cn: columnName,
|
||||
column_name: columnName,
|
||||
title: columnName,
|
||||
@@ -5632,6 +5704,10 @@ export class ColumnsService implements IColumnsService {
|
||||
param.base.id,
|
||||
param.source.id,
|
||||
{
|
||||
...(replayIds?.assocModelId ? { id: replayIds.assocModelId } : {}),
|
||||
...(replayIds?.assocDefaultViewId
|
||||
? { _sandboxDefaultViewId: replayIds.assocDefaultViewId }
|
||||
: {}),
|
||||
table_name: aTn,
|
||||
title: aTnAlias,
|
||||
// todo: sanitize
|
||||
@@ -5679,6 +5755,7 @@ export class ColumnsService implements IColumnsService {
|
||||
);
|
||||
|
||||
// todo: skip hm and bt if new type
|
||||
const hmBtRefOut: { childRelColId?: string; savedColumnId?: string } = {};
|
||||
await createHmAndBtColumn(
|
||||
context,
|
||||
param.req,
|
||||
@@ -5698,7 +5775,11 @@ export class ColumnsService implements IColumnsService {
|
||||
undefined,
|
||||
// not need to pass columnWebhookManager here
|
||||
undefined,
|
||||
replayIds?.hmBtCallRef,
|
||||
hmBtRefOut,
|
||||
);
|
||||
const hmBtTableOut: { childRelColId?: string; savedColumnId?: string } =
|
||||
{};
|
||||
await createHmAndBtColumn(
|
||||
context,
|
||||
param.req,
|
||||
@@ -5718,6 +5799,8 @@ export class ColumnsService implements IColumnsService {
|
||||
undefined,
|
||||
// not need to pass columnWebhookManager here
|
||||
undefined,
|
||||
replayIds?.hmBtCallTable,
|
||||
hmBtTableOut,
|
||||
);
|
||||
|
||||
let refCrossBaseLinkProps: {
|
||||
@@ -5774,6 +5857,7 @@ export class ColumnsService implements IColumnsService {
|
||||
: pluralize(refTable.title);
|
||||
|
||||
savedColumn = await Column.insert(context, {
|
||||
...(ltarReq.id ? { id: ltarReq.id } : {}),
|
||||
title: getUniqueColumnAliasName(
|
||||
await table.getColumns(context),
|
||||
param.column.title ?? defaultTitle,
|
||||
@@ -5823,6 +5907,9 @@ export class ColumnsService implements IColumnsService {
|
||||
: pluralize(table.title);
|
||||
|
||||
const parentRelCol = await Column.insert(refContext, {
|
||||
...(replayIds?.reverseColumnId
|
||||
? { id: replayIds.reverseColumnId }
|
||||
: {}),
|
||||
title: getUniqueColumnAliasName(
|
||||
[
|
||||
...(await refTable.getColumns(refContext)),
|
||||
@@ -5867,6 +5954,17 @@ export class ColumnsService implements IColumnsService {
|
||||
...refCrossBaseLinkProps,
|
||||
});
|
||||
|
||||
if (capture) {
|
||||
capture.assocModelId = assocModel.id;
|
||||
const assocViews = await assocModel.getViews(context);
|
||||
capture.assocDefaultViewId = assocViews?.[0]?.id;
|
||||
capture.reverseColumnId = parentRelCol.id;
|
||||
capture.assocChildColId = childCol.id;
|
||||
capture.assocParentColId = parentCol.id;
|
||||
capture.hmBtCallRef = hmBtRefOut;
|
||||
capture.hmBtCallTable = hmBtTableOut;
|
||||
}
|
||||
|
||||
this.appHooksService.emit(AppEvents.COLUMN_CREATE, {
|
||||
table: refTable,
|
||||
column: parentRelCol,
|
||||
|
||||
@@ -22,6 +22,43 @@ export interface ReusableParams {
|
||||
baseModel?: BaseModelSqlv2;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs created by `createHmAndBtColumn` / `createOOColumn`. Used for both
|
||||
* `idHints` (replay reads) and `out` (recording writes) in `columnHelpers`.
|
||||
*/
|
||||
export interface LtarHmBtIds {
|
||||
childRelColId?: string;
|
||||
savedColumnId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-effect IDs created by `createLTARColumn`. Captured into
|
||||
* `param._ltarCapture` during recording (surfaced via `extraCommandMeta`
|
||||
* on `ColumnAddContract`) and read back as `param._ltarReplayIds` on
|
||||
* replay so each insert site honors the recorded id. The savedColumn
|
||||
* itself uses `idField: 'column'` and isn't in this shape.
|
||||
*/
|
||||
export interface LtarSideEffectIds {
|
||||
/** FK column on `refTable` for hm/bt + oo paths. */
|
||||
fkColumnId?: string;
|
||||
/** Hidden mm junction model (mm path only). */
|
||||
assocModelId?: string;
|
||||
/**
|
||||
* Auto-created default grid view on the assoc model. Threaded into
|
||||
* `Model.insert` via `_sandboxDefaultViewId` so the production-side
|
||||
* view keeps the sandbox-side id.
|
||||
*/
|
||||
assocDefaultViewId?: string;
|
||||
/** Reverse LTAR column on `refTable`. */
|
||||
reverseColumnId?: string;
|
||||
/** FK columns on the assoc table (mm path) — pre-set on `associateTableCols` so `Column.bulkInsert` honors them. */
|
||||
assocChildColId?: string;
|
||||
assocParentColId?: string;
|
||||
/** Two `createHmAndBtColumn` calls for mm — assoc→ref and assoc→table. */
|
||||
hmBtCallRef?: LtarHmBtIds;
|
||||
hmBtCallTable?: LtarHmBtIds;
|
||||
}
|
||||
|
||||
export interface IColumnsService {
|
||||
columnAdd<T extends NcApiVersion = NcApiVersion | null | undefined>(
|
||||
context: NcContext,
|
||||
|
||||
@@ -489,7 +489,7 @@ export class DataTableService {
|
||||
) {
|
||||
listArgs.nestedLimit = param.query.limit;
|
||||
}
|
||||
let data: any[];
|
||||
let data: Record<string, any>[] | Record<string, any>;
|
||||
let count: number;
|
||||
|
||||
// V2 single-target relations (MO/OO) — junction table with LIMIT 1
|
||||
@@ -565,7 +565,7 @@ export class DataTableService {
|
||||
|
||||
if (colOptions.type === RelationTypes.BELONGS_TO) return data;
|
||||
|
||||
return new PagedResponseImpl(data, {
|
||||
return new PagedResponseImpl(data as Record<string, any>[], {
|
||||
count,
|
||||
...param.query,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NcError } from '~/helpers/ncError';
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionsService {
|
||||
constructor(private readonly appHooksService: AppHooksService) {}
|
||||
constructor(protected readonly appHooksService: AppHooksService) {}
|
||||
|
||||
async extensionList(context: NcContext, param: { baseId: string }) {
|
||||
return await Extension.list(context, param.baseId);
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user